openvibers/openvibe.shared Commits

Shared 4.0.0: a share card for every site (openvibe-shared/social, og:image by default) (#62)

OP OpenVibers committed 11 Oct 2026 · 059b69bc50 · 43 files, +312 −12

**Share cards for every OpenVibe site.** The SEO survey found `og:image` missing on 27 of 32 public homes, so a shared link showed no preview on Discord, X, Slack and the rest.

- `openvibe-shared/social` (new): one 1200×630 card per service in `social/<service>.jpg`, about 60 KB each (2.2 MB for all 34). The card shows the product name ("OpenVibe." + name in its accent; OpenRestream as "OpenRe" + "stream", like the navbar), its tagline and one-line "what", its domain and the app-icon mark.
- `scripts/build-social-cards.js` draws them from the Contracts service manifests' `site` blocks, so Contracts stays the source of truth. It renders with headless Chrome and embedded Inter, then converts with ImageMagick. Re-run it when a tagline, accent or domain changes.
- `serve.js` answers `/shared/social/<service>.jpg?v=<hash>` (immutable, CORS-open) beside the browser files. Nothing else under `social/` is served.
- `shell.page()` defaults `og:image` and `twitter:image` (with `summary_large_image`) to the site's card when the page names no image and has a canonical; `image: null` opts out. Pages that build their own head use `social.url(service)`.
- 4.0.0, not 3.1.0, because the unreleased C-05 change (`extractToken` drops the legacy `token` cookie) is breaking by this package's rule. No OpenVibers service imports that module.

Tests: new `test/social.test.js` (card sizes and dimensions, names, traversal, serve headers, the `shell.page` defaults); `serve`, `shell`, `package`, `seo`, `browser-bundles` and `docs-status` pass.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b15907a..655d1f8 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
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 Chat
stopped reading it (compatibility register C-05). No OpenVibers service imports this module today.
ddiff --git a/README.md b/README.md
iindex 0b0fd0a..030a42f 100644
---- a/README.md
++++ b/README.md
@@@ -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) |
ddiff --git a/STATUS.json b/STATUS.json
iindex 51fa539..8a7be55 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/package.json b/package.json
iindex 93f6fdf..58f511c 100644
---- a/package.json
++++ b/package.json
@@@ -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"
],
ddiff --git a/scripts/build-social-cards.js b/scripts/build-social-cards.js
nnew file mode 100644
iindex 0000000..7999a24
---- /dev/null
++++ b/scripts/build-social-cards.js
@@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
++
++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); });
ddiff --git a/serve.js b/serve.js
iindex 1535b96..12f859d 100644
---- a/serve.js
++++ b/serve.js
@@@ -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);
};
}
ddiff --git a/shell.js b/shell.js
iindex 3996229..8a8ba37 100644
---- a/shell.js
++++ b/shell.js
@@@ -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,
ddiff --git a/social.js b/social.js
nnew file mode 100644
iindex 0000000..514d5ff
---- /dev/null
++++ b/social.js
@@@ -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 };
ddiff --git a/social/actor.jpg b/social/actor.jpg
nnew file mode 100644
iindex 0000000..f46ca97
BBinary files /dev/null and b/social/actor.jpg differ
ddiff --git a/social/ai.jpg b/social/ai.jpg
nnew file mode 100644
iindex 0000000..9eb3616
BBinary files /dev/null and b/social/ai.jpg differ
ddiff --git a/social/blog.jpg b/social/blog.jpg
nnew file mode 100644
iindex 0000000..740fbca
BBinary files /dev/null and b/social/blog.jpg differ
ddiff --git a/social/bot.jpg b/social/bot.jpg
nnew file mode 100644
iindex 0000000..460f14a
BBinary files /dev/null and b/social/bot.jpg differ
ddiff --git a/social/chat.jpg b/social/chat.jpg
nnew file mode 100644
iindex 0000000..29b88e7
BBinary files /dev/null and b/social/chat.jpg differ
ddiff --git a/social/codes.jpg b/social/codes.jpg
nnew file mode 100644
iindex 0000000..97ffaec
BBinary files /dev/null and b/social/codes.jpg differ
ddiff --git a/social/community.jpg b/social/community.jpg
nnew file mode 100644
iindex 0000000..2426aa8
BBinary files /dev/null and b/social/community.jpg differ
ddiff --git a/social/coupons.jpg b/social/coupons.jpg
nnew file mode 100644
iindex 0000000..58bfe29
BBinary files /dev/null and b/social/coupons.jpg differ
ddiff --git a/social/deals.jpg b/social/deals.jpg
nnew file mode 100644
iindex 0000000..896fd93
BBinary files /dev/null and b/social/deals.jpg differ
ddiff --git a/social/events.jpg b/social/events.jpg
nnew file mode 100644
iindex 0000000..740e26b
BBinary files /dev/null and b/social/events.jpg differ
ddiff --git a/social/food.jpg b/social/food.jpg
nnew file mode 100644
iindex 0000000..78df51c
BBinary files /dev/null and b/social/food.jpg differ
ddiff --git a/social/games.jpg b/social/games.jpg
nnew file mode 100644
iindex 0000000..51c44a0
BBinary files /dev/null and b/social/games.jpg differ
ddiff --git a/social/help.jpg b/social/help.jpg
nnew file mode 100644
iindex 0000000..caa9168
BBinary files /dev/null and b/social/help.jpg differ
ddiff --git a/social/host.jpg b/social/host.jpg
nnew file mode 100644
iindex 0000000..02d0220
BBinary files /dev/null and b/social/host.jpg differ
ddiff --git a/social/inventory.jpg b/social/inventory.jpg
nnew file mode 100644
iindex 0000000..7f6bb66
BBinary files /dev/null and b/social/inventory.jpg differ
ddiff --git a/social/live.jpg b/social/live.jpg
nnew file mode 100644
iindex 0000000..a5ec295
BBinary files /dev/null and b/social/live.jpg differ
ddiff --git a/social/media-hub.jpg b/social/media-hub.jpg
nnew file mode 100644
iindex 0000000..6d2f3d2
BBinary files /dev/null and b/social/media-hub.jpg differ
ddiff --git a/social/media.jpg b/social/media.jpg
nnew file mode 100644
iindex 0000000..84d2a8b
BBinary files /dev/null and b/social/media.jpg differ
ddiff --git a/social/network.jpg b/social/network.jpg
nnew file mode 100644
iindex 0000000..de8c8c7
BBinary files /dev/null and b/social/network.jpg differ
ddiff --git a/social/news.jpg b/social/news.jpg
nnew file mode 100644
iindex 0000000..646a753
BBinary files /dev/null and b/social/news.jpg differ
ddiff --git a/social/openre.jpg b/social/openre.jpg
nnew file mode 100644
iindex 0000000..e91c51f
BBinary files /dev/null and b/social/openre.jpg differ
ddiff --git a/social/quest.jpg b/social/quest.jpg
nnew file mode 100644
iindex 0000000..93341f1
BBinary files /dev/null and b/social/quest.jpg differ
ddiff --git a/social/rent.jpg b/social/rent.jpg
nnew file mode 100644
iindex 0000000..91f5065
BBinary files /dev/null and b/social/rent.jpg differ
ddiff --git a/social/reviews.jpg b/social/reviews.jpg
nnew file mode 100644
iindex 0000000..068bbe6
BBinary files /dev/null and b/social/reviews.jpg differ
ddiff --git a/social/search.jpg b/social/search.jpg
nnew file mode 100644
iindex 0000000..07f7521
BBinary files /dev/null and b/social/search.jpg differ
ddiff --git a/social/services.jpg b/social/services.jpg
nnew file mode 100644
iindex 0000000..98795df
BBinary files /dev/null and b/social/services.jpg differ
ddiff --git a/social/space.jpg b/social/space.jpg
nnew file mode 100644
iindex 0000000..54e92e94
BBinary files /dev/null and b/social/space.jpg differ
ddiff --git a/social/tips.jpg b/social/tips.jpg
nnew file mode 100644
iindex 0000000..2cfa417
BBinary files /dev/null and b/social/tips.jpg differ
ddiff --git a/social/tools.jpg b/social/tools.jpg
nnew file mode 100644
iindex 0000000..7ff7ed7
BBinary files /dev/null and b/social/tools.jpg differ
ddiff --git a/social/trade.jpg b/social/trade.jpg
nnew file mode 100644
iindex 0000000..e868d44
BBinary files /dev/null and b/social/trade.jpg differ
ddiff --git a/social/vip.jpg b/social/vip.jpg
nnew file mode 100644
iindex 0000000..b575e57
BBinary files /dev/null and b/social/vip.jpg differ
ddiff --git a/social/watch.jpg b/social/watch.jpg
nnew file mode 100644
iindex 0000000..be9871d
BBinary files /dev/null and b/social/watch.jpg differ
ddiff --git a/social/wiki.jpg b/social/wiki.jpg
nnew file mode 100644
iindex 0000000..9b98884
BBinary files /dev/null and b/social/wiki.jpg differ
ddiff --git a/social/work.jpg b/social/work.jpg
nnew file mode 100644
iindex 0000000..e677632
BBinary files /dev/null and b/social/work.jpg differ
ddiff --git a/test/social.test.js b/test/social.test.js
nnew file mode 100644
iindex 0000000..61b4df9
---- /dev/null
++++ b/test/social.test.js
@@@ -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); });