openvibers/openvibe.shared social.js

social.js

main 7.8 KB · 115 lines · javascript

1'use strict';
2// ═══════════════════════════════════════════════════════════════
3// openvibe-shared/social — every OpenVibe site's share card (og:image / twitter:image).
4//
5// The cards are 1200×630 JPEGs in social/<service>.jpg, drawn by scripts/build-social-cards.js from the service
6// manifests' `site` blocks (name, tagline, first domain, accent) with the app-icon mark; a site's big pages have page
7// cards, social/<service>-<page>.jpg (live-rundown, live-content, …), listed in PAGES below. A site serves its pinned
8// copy at /shared/social/<service>.jpg through openvibe-shared/serve, so a card never depends on another host.
9//
10// social.has(service) is there a card for this service id
11// social.url(service, base?) '/shared/social/<service>.jpg?v=<content hash>', or null without a card
12// social.isCard(name) 'social/<id>.jpg' names serve.js may answer
13// social.path(name) the file on disk (throws for anything that is not a card)
14// social.accentOf(service, accent?) the two accent stops the card uses (build script)
15// social.iconSite(service) the app-icon variant for the service's mark (build script)
16// social.pageOf(id) / social.PAGES the page cards: which page and site a card id names (null for a site's card)
17// social.domainOf(id) / social.domainForHost(host) / social.DOMAINS a service's other named domains (openvibe.pics)
18//
19// shell.page() uses social.url(o.service) for og:image when the page names no image of its own, or the domain's card
20// when the page's canonical URL is on one of DOMAINS' hosts.
21// ═══════════════════════════════════════════════════════════════
22const fs = require('fs');
23const path = require('path');
24const { hashAsset } = require('./assets');
25const icon = require('./app-icon');
26
27const DIR = path.join(__dirname, 'social');
28const NAME_RE = /^social\/([a-z0-9][a-z0-9-]{0,40})\.jpg$/;
29const ID_RE = /^[a-z0-9][a-z0-9-]{0,40}$/;
30const hashes = new Map();
31
32function isCard(name) {
33 const m = NAME_RE.exec(String(name || ''));
34 return Boolean(m) && fs.existsSync(path.join(DIR, `${m[1]}.jpg`));
35}
36
37function cardPath(name) {
38 if (!isCard(name)) throw new Error(`openvibe-shared/social: ${name} is not a share card`);
39 return path.join(__dirname, name);
40}
41
42// Page cards (social/<service>-<page>.jpg): a site's big pages, shared on their own, get a card that names the page in
43// place of the site, with the site's brand beside the mark and the page's address in the pill. Drawn by
44// scripts/build-social-cards.js from this list; every other card is a site's.
45const PAGES = [
46 { id: 'live-rundown', service: 'live', heading: 'The Rundown', path: '/rundown', tagline: 'What streamers talked about',
47 what: "Topics, takes and episodes from OpenVibe.Live streams, in the streamers' own words, with a link to every moment." },
48 { id: 'live-content', service: 'live', heading: 'Content', path: '/content', tagline: 'Recordings, clips and pastes',
49 what: 'What people made on OpenVibe.Live: recorded streams, the moments viewers clipped, and the code and notes they shared.' },
50 { id: 'live-moments', service: 'live', heading: 'AI Moments', path: '/moments', tagline: 'Highlights the AI picked',
51 what: 'Auto-clips of the moments chat erupted, standout frames and after-show recaps, each one labelled AI-made.' },
52 { id: 'live-chat', service: 'live', heading: 'Chat', path: '/chat', tagline: 'The global chat room',
53 what: 'One chat room for everyone on OpenVibe.Live, across every stream, open to the whole network.' },
54];
55/** The page a card id names ({ id, service, heading, … }), or null for a site's own card. */
56const pageOf = (id) => PAGES.find((pg) => pg.id === id) || null;
57
58// Domain cards (social/<id>.jpg): a service's other domains that wear their own name on the navbar (openvibe.pics is
59// MediaHub's, openvibe.homes is Rent's). Each gets a site card of its own, drawn like its service's (the same mark;
60// its own accent when it has one), and shell.page() shares it on any page whose canonical URL is on that host. Before
61// 4.86.1 a link to openvibe.pics previewed as OpenVibe.Video.
62const DOMAINS = [
63 { id: 'pics', service: 'media-hub', host: 'openvibe.pics', name: 'Pics', accent: '#f472b6', tagline: 'Share pictures. Fast, open, yours',
64 what: 'A link to share or embed any picture, in any size or format, with location and camera details removed.' },
65 { id: 'download', service: 'media-hub', host: 'openvibe.download', name: 'Download', tagline: 'Your files, shared on your terms',
66 what: 'A private drive with 25 GB free: share files with links you can take back, and back up a folder.' },
67 { id: 'homes', service: 'rent', host: 'openvibe.homes', name: 'Homes', tagline: 'Every home for sale, one search',
68 what: 'Zillow, Realtor.com, Redfin, Homes.com, Trulia and Craigslist searched at once, plus HUD and bank homes.' },
69 { id: 'website', service: 'space', host: 'openvibe.website', name: 'Website', tagline: 'Websites that look finished',
70 what: 'Start from a real shop, portfolio, event, link page or launch page, or describe yours, and change anything from the first preview.' },
71];
72/** The domain card an id names ({ id, service, host, name, … }), or null. */
73const domainOf = (id) => DOMAINS.find((d) => d.id === id) || null;
74/** The domain card for a host (a www. prefix and a port ignored), or null. */
75function domainForHost(host) {
76 const h = String(host || '').toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '');
77 return DOMAINS.find((d) => d.host === h) || null;
78}
79
80const has = (service) => ID_RE.test(String(service || '')) && isCard(`social/${service}.jpg`);
81
82function url(service, base = '/shared') {
83 if (!has(service)) return null;
84 const name = `social/${service}.jpg`;
85 if (!hashes.has(name)) hashes.set(name, hashAsset(fs.readFileSync(cardPath(name))));
86 return `${String(base).replace(/\/+$/, '')}/${name}?v=${hashes.get(name)}`;
87}
88
89// The mark's tint: app-icon has variants for a few sites; the rest wear the network's.
90// A domain card wears its service's mark.
91function iconSite(service) {
92 const d = domainOf(service);
93 if (d) return iconSite(d.service);
94 return Object.prototype.hasOwnProperty.call(icon.SITES, service) ? service : service === 'media-hub' ? 'media' : 'network';
95}
96
97/** A deeper stop of the same hue (lightness down 16 points, saturation up 6), so a gradient never turns muddy. */
98function deepen(hex) {
99 const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255);
100 const max = Math.max(r, g, b), min = Math.min(r, g, b), l = (max + min) / 2, d = max - min;
101 const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
102 const h = d === 0 ? 0 : max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
103 const L = Math.max(0, l - 0.16), S = Math.min(1, s + 0.06);
104 const c = (1 - Math.abs(2 * L - 1)) * S, x = c * (1 - Math.abs(((h % 6) + 6) % 2 - 1)), m = L - c / 2;
105 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];
106 return `#${[R, G, B].map((v) => Math.round((v + m) * 255).toString(16).padStart(2, '0')).join('')}`;
107}
108
109/** [light, deep] accent stops: the manifest's accent and a deeper stop of it, else the app-icon variant's. */
110function accentOf(service, accent) {
111 return /^#[0-9a-f]{6}$/i.test(accent || '') ? [accent.toLowerCase(), deepen(accent)] : icon.SITES[iconSite(service)].accent;
112}
113
114module.exports = { has, url, isCard, path: cardPath, accentOf, iconSite, pageOf, PAGES, domainOf, domainForHost, DOMAINS, DIR };
115