openvibers/openvibe.shared Commits
4.6.0: page share cards, and Live's card says go live from your browser (#89)
Changes
@@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 4.6.0 — 2026-10-20++++- **Page cards.** A site's big pages get a share card of their own, `social/<service>-<page>.jpg`, drawn like the site++ card with the page's name as the headline, the site's brand beside the mark and the page's address in the pill:++ `live-rundown` (The Rundown), `live-content`, `live-moments` (AI Moments) and `live-chat`. They are listed in++ `scripts/build-social-cards.js` (`PAGES`); `social.url('live-rundown')` and `/shared/social/live-rundown.jpg` work++ like a site card. `--only live` renders a site with its page cards.++- **Live's card re-rendered** from Contracts 0.205.4: "Go live from your browser", with no OBS, no downloads and no++ follower minimum underneath (it said "Streams, clips and chat").++## 4.5.0 — 2026-10-20- **Video sitemaps.** `seo.sitemapXml` takes `videos` on a URL: `{ thumbnail_loc, title, description, content_loc@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "4.5.0",++ "version": "4.6.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "4.5.0",++ "version": "4.6.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "4.5.0",++ "version": "4.6.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","bin": {@@@ -1,7 +1,9 @@#!/usr/bin/env node'use strict';--// Render the share card (og:image, 1200×630 JPEG) of every OpenVibe site into social/<service>.jpg.++// Render the share card (og:image, 1200×630 JPEG) of every OpenVibe site into social/<service>.jpg, and the page cards++// (openvibe-shared/social PAGES) into social/<service>-<page>.jpg.// node scripts/build-social-cards.js --contracts <OpenVibe.Contracts checkout> [--chrome <path>] [--only <id,id>]++// --only takes site ids (a site's page cards come with it) or page card ids.// 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@@@ -51,11 +53,20 @@ async function interFaces() {function sites() {const dir = path.join(contracts, 'manifests', 'services');const out = [];++ const byId = new Map();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;++ const site = { 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) };++ byId.set(m.id, site);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) });++ out.push(site);++ }++ for (const pg of social.PAGES) {++ const site = byId.get(pg.service);++ if (!site) throw new Error(`page card ${pg.id}: no site ${pg.service} in the manifests`);++ if (only && !only.has(pg.id) && !only.has(pg.service)) continue;++ out.push({ ...site, id: pg.id, heading: pg.heading, tagline: pg.tagline, what: pg.what, domain: `${site.domain}${pg.path}`, iconId: site.id });}return out;}@@@ -65,10 +76,12 @@ function card(site, fonts) {// 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++ const siteBrand = 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;++ // A page card: the page's name is the headline and the site's brand takes the line beside the mark.++ const brand = site.heading ? `<span class="p">${esc(site.heading)}</span>` : siteBrand;++ const long = (site.heading || site.name).length > 9;return `<!doctype html><html><head><meta charset="utf-8"><style>${fonts}*{margin:0;padding:0;box-sizing:border-box}@@@ -87,6 +100,9 @@ body{font-family:'Inter',sans-serif;color:#f8fafc;background:#0a0f1c;position:re.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}++.net.site{font-size:34px;font-weight:800;letter-spacing:-.02em}++.net.site .w{color:#f8fafc}.net.site .d{color:${a1}}++.net.site .p{background:linear-gradient(100deg, ${a1}, ${a2});-webkit-background-clip:text;background-clip:text;color:transparent}.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}@@@ -102,7 +118,7 @@ body{font-family:'Inter',sans-serif;color:#f8fafc;background:#0a0f1c;position:re</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="top"><div class="icon">${icon.svg({ site: social.iconSite(site.iconId || site.id), maskable: false })}</div>${site.heading ? `<div class="net site">${siteBrand}</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>` : ''}@@@ -3,7 +3,8 @@// 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++// manifests' `site` blocks (name, tagline, first domain, accent) with the app-icon mark; a site's big pages have page++// cards, social/<service>-<page>.jpg (live-rundown, live-content, …), listed in PAGES below. 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@@@ -12,6 +13,7 @@// 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)++// social.pageOf(id) / social.PAGES the page cards: which page and site a card id names (null for a site's card)//// shell.page() uses social.url(o.service) for og:image when the page names no image of its own.// ═══════════════════════════════════════════════════════════════@@@ -35,6 +37,22 @@ function cardPath(name) {return path.join(__dirname, name);}++// Page cards (social/<service>-<page>.jpg): a site's big pages, shared on their own, get a card that names the page in++// place of the site, with the site's brand beside the mark and the page's address in the pill. Drawn by++// scripts/build-social-cards.js from this list; every other card is a site's.++const PAGES = [++ { id: 'live-rundown', service: 'live', heading: 'The Rundown', path: '/rundown', tagline: 'What streamers talked about',++ what: "Topics, takes and episodes from OpenVibe.Live streams, in the streamers' own words, with a link to every moment." },++ { id: 'live-content', service: 'live', heading: 'Content', path: '/content', tagline: 'Recordings, clips and pastes',++ what: 'What people made on OpenVibe.Live: recorded streams, the moments viewers clipped, and the code and notes they shared.' },++ { id: 'live-moments', service: 'live', heading: 'AI Moments', path: '/moments', tagline: 'Highlights the AI picked',++ what: 'Auto-clips of the moments chat erupted, standout frames and after-show recaps, each one labelled AI-made.' },++ { id: 'live-chat', service: 'live', heading: 'Chat', path: '/chat', tagline: 'The global chat room',++ what: 'One chat room for everyone on OpenVibe.Live, across every stream, open to the whole network.' },++];++/** The page a card id names ({ id, service, heading, … }), or null for a site's own card. */++const pageOf = (id) => PAGES.find((pg) => pg.id === id) || null;++const has = (service) => ID_RE.test(String(service || '')) && isCard(`social/${service}.jpg`);function url(service, base = '/shared') {@@@ -64,4 +82,4 @@ 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 };++module.exports = { has, url, isCard, path: cardPath, accentOf, iconSite, pageOf, PAGES, DIR };nnew file mode 100644BBinary files /dev/null and b/social/live-chat.jpg differnnew file mode 100644BBinary files /dev/null and b/social/live-content.jpg differnnew file mode 100644BBinary files /dev/null and b/social/live-moments.jpg differnnew file mode 100644BBinary files /dev/null and b/social/live-rundown.jpg differBBinary files a/social/live.jpg and b/social/live.jpg differ@@@ -20,7 +20,8 @@ function pngSize(buf) {(async () => {// Every service with a share card has its four icons, valid PNGs of the right size.-- const services = fs.readdirSync(social.DIR).filter((f) => f.endsWith('.jpg')).map((f) => f.slice(0, -4)).sort();++ // A page card (live-rundown, …) belongs to its site, which has the icons.++ const services = fs.readdirSync(social.DIR).filter((f) => f.endsWith('.jpg')).map((f) => f.slice(0, -4)).filter((id) => !social.pageOf(id)).sort();assert.ok(services.length >= 30, `a card for every site (${services.length})`);const variants = new Set();for (const service of services) {@@@ -35,6 +35,12 @@ function jpegSize(buf) {}assert.ok(social.has('news') && social.has('openre') && social.has('network'));++ // Page cards: a site's big pages have their own (scripts/build-social-cards.js PAGES), named <service>-<page>.++ assert.deepStrictEqual(social.PAGES.map((pg) => pg.id), ['live-rundown', 'live-content', 'live-moments', 'live-chat']);++ assert.strictEqual(social.pageOf('live-rundown').service, 'live');++ assert.strictEqual(social.pageOf('live'), null, 'a site card is no page card');++ for (const pg of social.PAGES) assert.ok(social.has(pg.service), `${pg.id}: its site has a card`);++ for (const id of ['live-rundown', 'live-content', 'live-moments', 'live-chat']) assert.match(social.url(id), new RegExp(`^/shared/social/${id}\\.jpg\\?v=[0-9a-f]{12}$`), id);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=/);
@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 4.6.0 — 2026-10-20
+
+- **Page cards.** A site's big pages get a share card of their own, `social/<service>-<page>.jpg`, drawn like the site
+ card with the page's name as the headline, the site's brand beside the mark and the page's address in the pill:
+ `live-rundown` (The Rundown), `live-content`, `live-moments` (AI Moments) and `live-chat`. They are listed in
+ `scripts/build-social-cards.js` (`PAGES`); `social.url('live-rundown')` and `/shared/social/live-rundown.jpg` work
+ like a site card. `--only live` renders a site with its page cards.
+- **Live's card re-rendered** from Contracts 0.205.4: "Go live from your browser", with no OBS, no downloads and no
+ follower minimum underneath (it said "Streams, clips and chat").
+
## 4.5.0 — 2026-10-20
## 4.5.0 — 2026-10-20
- **Video sitemaps.** `seo.sitemapXml` takes `videos` on a URL: `{ thumbnail_loc, title, description, content_loc
- **Video sitemaps.** `seo.sitemapXml` takes `videos` on a URL: `{ thumbnail_loc, title, description, content_loc
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.5.0",
+ "version": "4.6.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.5.0",
+ "version": "4.6.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "4.5.0",
+ "version": "4.6.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": {
@@ -1,7 +1,9 @@
@@ -1,7 +1,9 @@
#!/usr/bin/env node
#!/usr/bin/env node
'use strict';
'use strict';
-// Render the share card (og:image, 1200×630 JPEG) of every OpenVibe site into social/<service>.jpg.
+// Render the share card (og:image, 1200×630 JPEG) of every OpenVibe site into social/<service>.jpg, and the page cards
+// (openvibe-shared/social PAGES) into social/<service>-<page>.jpg.
// node scripts/build-social-cards.js --contracts <OpenVibe.Contracts checkout> [--chrome <path>] [--only <id,id>]
// node scripts/build-social-cards.js --contracts <OpenVibe.Contracts checkout> [--chrome <path>] [--only <id,id>]
+// --only takes site ids (a site's page cards come with it) or page card ids.
// Needs Chrome (the drawing) and ImageMagick's `convert` (PNG → a ~60 KB progressive JPEG).
// 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
// 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
// truth; this script only draws them). The mark is openvibe-shared/app-icon. Inter is fetched once from Google Fonts
@@ -51,11 +53,20 @@ async function interFaces() {
@@ -51,11 +53,20 @@ async function interFaces() {
function sites() {
function sites() {
const dir = path.join(contracts, 'manifests', 'services');
const dir = path.join(contracts, 'manifests', 'services');
const out = [];
const out = [];
+ const byId = new Map();
for (const f of fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {
for (const f of fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {
const m = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
const m = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8'));
if (!m.site || !Array.isArray(m.domains) || !m.domains.length) continue;
if (!m.site || !Array.isArray(m.domains) || !m.domains.length) continue;
+ const site = { 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) };
+ byId.set(m.id, site);
if (only && !only.has(m.id)) 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) });
+ out.push(site);
+ }
+ for (const pg of social.PAGES) {
+ const site = byId.get(pg.service);
+ if (!site) throw new Error(`page card ${pg.id}: no site ${pg.service} in the manifests`);
+ if (only && !only.has(pg.id) && !only.has(pg.service)) continue;
+ out.push({ ...site, id: pg.id, heading: pg.heading, tagline: pg.tagline, what: pg.what, domain: `${site.domain}${pg.path}`, iconId: site.id });
}
}
return out;
return out;
}
}
@@ -65,10 +76,12 @@ function card(site, fonts) {
@@ -65,10 +76,12 @@ function card(site, fonts) {
// The product name after "OpenVibe.", unless the product carries its own name: OpenRestream reads "OpenRe" +
// 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.
// "stream" as one word, the way the navbar draws it.
const own = /^Open(Re)(.+)$/.exec(site.name);
const own = /^Open(Re)(.+)$/.exec(site.name);
- const brand = own
+ const siteBrand = own
? `<span class="w">Open${own[1]}</span><span class="p">${esc(own[2])}</span>`
? `<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>`;
: `<span class="w">OpenVibe</span><span class="d">.</span><span class="p">${esc(site.name)}</span>`;
- const long = site.name.length > 9;
+ // A page card: the page's name is the headline and the site's brand takes the line beside the mark.
+ const brand = site.heading ? `<span class="p">${esc(site.heading)}</span>` : siteBrand;
+ const long = (site.heading || site.name).length > 9;
return `<!doctype html><html><head><meta charset="utf-8"><style>
return `<!doctype html><html><head><meta charset="utf-8"><style>
${fonts}
${fonts}
*{margin:0;padding:0;box-sizing:border-box}
*{margin:0;padding:0;box-sizing:border-box}
@@ -87,6 +100,9 @@ body{font-family:'Inter',sans-serif;color:#f8fafc;background:#0a0f1c;position:re
@@ -87,6 +100,9 @@ body{font-family:'Inter',sans-serif;color:#f8fafc;background:#0a0f1c;position:re
.icon{width:88px;height:88px;border-radius:22px;overflow:hidden;box-shadow:0 18px 50px -12px ${a2}88, 0 0 0 1px #ffffff1a}
.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}
.icon svg{width:88px;height:88px;display:block}
.net{font-size:26px;font-weight:600;color:#94a3b8;letter-spacing:.01em}
.net{font-size:26px;font-weight:600;color:#94a3b8;letter-spacing:.01em}
+.net.site{font-size:34px;font-weight:800;letter-spacing:-.02em}
+.net.site .w{color:#f8fafc}.net.site .d{color:${a1}}
+.net.site .p{background:linear-gradient(100deg, ${a1}, ${a2});-webkit-background-clip:text;background-clip:text;color:transparent}
.name{margin-top:36px;font-size:${long ? 92 : 108}px;font-weight:800;letter-spacing:-.035em;line-height:1}
.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 .w{color:#f8fafc}.name .d{color:${a1}}
.name .p{background:linear-gradient(100deg, ${a1}, ${a2});-webkit-background-clip:text;background-clip:text;color:transparent}
.name .p{background:linear-gradient(100deg, ${a1}, ${a2});-webkit-background-clip:text;background-clip:text;color:transparent}
@@ -102,7 +118,7 @@ body{font-family:'Inter',sans-serif;color:#f8fafc;background:#0a0f1c;position:re
@@ -102,7 +118,7 @@ body{font-family:'Inter',sans-serif;color:#f8fafc;background:#0a0f1c;position:re
</style></head><body>
</style></head><body>
<div class="glow"></div><div class="grid"></div><div class="ring"></div><div class="ring2"></div>
<div class="glow"></div><div class="grid"></div><div class="ring"></div><div class="ring2"></div>
<div class="wrap">
<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="top"><div class="icon">${icon.svg({ site: social.iconSite(site.iconId || site.id), maskable: false })}</div>${site.heading ? `<div class="net site">${siteBrand}</div>` : '<div class="net">One account. All of OpenVibe.</div>'}</div>
<div class="name">${brand}</div>
<div class="name">${brand}</div>
<div class="tag">${esc(site.tagline)}</div>
<div class="tag">${esc(site.tagline)}</div>
${site.what ? `<div class="what">${esc(site.what)}</div>` : ''}
${site.what ? `<div class="what">${esc(site.what)}</div>` : ''}
@@ -3,7 +3,8 @@
@@ -3,7 +3,8 @@
// openvibe-shared/social — every OpenVibe site's share card (og:image / twitter:image).
// 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
// 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
+// manifests' `site` blocks (name, tagline, first domain, accent) with the app-icon mark; a site's big pages have page
+// cards, social/<service>-<page>.jpg (live-rundown, live-content, …), listed in PAGES below. A site serves its pinned
// copy at /shared/social/<service>.jpg through openvibe-shared/serve, so a card never depends on another host.
// 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.has(service) is there a card for this service id
@@ -12,6 +13,7 @@
@@ -12,6 +13,7 @@
// social.path(name) the file on disk (throws for anything that is not a card)
// 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.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)
// social.iconSite(service) the app-icon variant for the service's mark (build script)
+// social.pageOf(id) / social.PAGES the page cards: which page and site a card id names (null for a site's card)
//
//
// shell.page() uses social.url(o.service) for og:image when the page names no image of its own.
// shell.page() uses social.url(o.service) for og:image when the page names no image of its own.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
@@ -35,6 +37,22 @@ function cardPath(name) {
@@ -35,6 +37,22 @@ function cardPath(name) {
return path.join(__dirname, name);
return path.join(__dirname, name);
}
}
+// Page cards (social/<service>-<page>.jpg): a site's big pages, shared on their own, get a card that names the page in
+// place of the site, with the site's brand beside the mark and the page's address in the pill. Drawn by
+// scripts/build-social-cards.js from this list; every other card is a site's.
+const PAGES = [
+ { id: 'live-rundown', service: 'live', heading: 'The Rundown', path: '/rundown', tagline: 'What streamers talked about',
+ what: "Topics, takes and episodes from OpenVibe.Live streams, in the streamers' own words, with a link to every moment." },
+ { id: 'live-content', service: 'live', heading: 'Content', path: '/content', tagline: 'Recordings, clips and pastes',
+ what: 'What people made on OpenVibe.Live: recorded streams, the moments viewers clipped, and the code and notes they shared.' },
+ { id: 'live-moments', service: 'live', heading: 'AI Moments', path: '/moments', tagline: 'Highlights the AI picked',
+ what: 'Auto-clips of the moments chat erupted, standout frames and after-show recaps, each one labelled AI-made.' },
+ { id: 'live-chat', service: 'live', heading: 'Chat', path: '/chat', tagline: 'The global chat room',
+ what: 'One chat room for everyone on OpenVibe.Live, across every stream, open to the whole network.' },
+];
+/** The page a card id names ({ id, service, heading, … }), or null for a site's own card. */
+const pageOf = (id) => PAGES.find((pg) => pg.id === id) || null;
+
const has = (service) => ID_RE.test(String(service || '')) && isCard(`social/${service}.jpg`);
const has = (service) => ID_RE.test(String(service || '')) && isCard(`social/${service}.jpg`);
function url(service, base = '/shared') {
function url(service, base = '/shared') {
@@ -64,4 +82,4 @@ function accentOf(service, accent) {
@@ -64,4 +82,4 @@ function accentOf(service, accent) {
return /^#[0-9a-f]{6}$/i.test(accent || '') ? [accent.toLowerCase(), deepen(accent)] : icon.SITES[iconSite(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 };
+module.exports = { has, url, isCard, path: cardPath, accentOf, iconSite, pageOf, PAGES, DIR };
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/live-chat.jpg differ
Binary files /dev/null and b/social/live-chat.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/live-content.jpg differ
Binary files /dev/null and b/social/live-content.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/live-moments.jpg differ
Binary files /dev/null and b/social/live-moments.jpg differ
new file mode 100644
new file mode 100644
Binary files /dev/null and b/social/live-rundown.jpg differ
Binary files /dev/null and b/social/live-rundown.jpg differ
Binary files a/social/live.jpg and b/social/live.jpg differ
Binary files a/social/live.jpg and b/social/live.jpg differ
@@ -20,7 +20,8 @@ function pngSize(buf) {
@@ -20,7 +20,8 @@ function pngSize(buf) {
(async () => {
(async () => {
// Every service with a share card has its four icons, valid PNGs of the right size.
// Every service with a share card has its four icons, valid PNGs of the right size.
- const services = fs.readdirSync(social.DIR).filter((f) => f.endsWith('.jpg')).map((f) => f.slice(0, -4)).sort();
+ // A page card (live-rundown, …) belongs to its site, which has the icons.
+ const services = fs.readdirSync(social.DIR).filter((f) => f.endsWith('.jpg')).map((f) => f.slice(0, -4)).filter((id) => !social.pageOf(id)).sort();
assert.ok(services.length >= 30, `a card for every site (${services.length})`);
assert.ok(services.length >= 30, `a card for every site (${services.length})`);
const variants = new Set();
const variants = new Set();
for (const service of services) {
for (const service of services) {
@@ -35,6 +35,12 @@ function jpegSize(buf) {
@@ -35,6 +35,12 @@ function jpegSize(buf) {
}
}
assert.ok(social.has('news') && social.has('openre') && social.has('network'));
assert.ok(social.has('news') && social.has('openre') && social.has('network'));
+ // Page cards: a site's big pages have their own (scripts/build-social-cards.js PAGES), named <service>-<page>.
+ assert.deepStrictEqual(social.PAGES.map((pg) => pg.id), ['live-rundown', 'live-content', 'live-moments', 'live-chat']);
+ assert.strictEqual(social.pageOf('live-rundown').service, 'live');
+ assert.strictEqual(social.pageOf('live'), null, 'a site card is no page card');
+ for (const pg of social.PAGES) assert.ok(social.has(pg.service), `${pg.id}: its site has a card`);
+ for (const id of ['live-rundown', 'live-content', 'live-moments', 'live-chat']) assert.match(social.url(id), new RegExp(`^/shared/social/${id}\\.jpg\\?v=[0-9a-f]{12}$`), id);
assert.ok(!social.has('nope') && !social.has('../news') && !social.has('') && !social.has(null));
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'), /^\/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.match(social.url('news', 'https://cdn.example/s/'), /^https:\/\/cdn\.example\/s\/social\/news\.jpg\?v=/);