openvibers/openvibe.shared social.js
social.js
| 1 | ; |
| 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 | // ═══════════════════════════════════════════════════════════════ |
| 22 | const fs = require('fs'); |
| 23 | const path = require('path'); |
| 24 | const { hashAsset } = require('./assets'); |
| 25 | const icon = require('./app-icon'); |
| 26 | |
| 27 | const DIR = path.join(__dirname, 'social'); |
| 28 | const NAME_RE = /^social\/([a-z0-9][a-z0-9-]{0,40})\.jpg$/; |
| 29 | const ID_RE = /^[a-z0-9][a-z0-9-]{0,40}$/; |
| 30 | const hashes = new Map(); |
| 31 | |
| 32 | function isCard(name) { |
| 33 | const m = NAME_RE.exec(String(name || '')); |
| 34 | return Boolean(m) && fs.existsSync(path.join(DIR, `${m[1]}.jpg`)); |
| 35 | } |
| 36 | |
| 37 | function 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. |
| 45 | const 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. */ |
| 56 | const 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. |
| 62 | const 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. */ |
| 73 | const 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. */ |
| 75 | function domainForHost(host) { |
| 76 | const h = String(host || '').toLowerCase().replace(/:\d+$/, '').replace(/^www\./, ''); |
| 77 | return DOMAINS.find((d) => d.host === h) || null; |
| 78 | } |
| 79 | |
| 80 | const has = (service) => ID_RE.test(String(service || '')) && isCard(`social/${service}.jpg`); |
| 81 | |
| 82 | function 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. |
| 91 | function 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. */ |
| 98 | function 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. */ |
| 110 | function accentOf(service, accent) { |
| 111 | return /^#[0-9a-f]{6}$/i.test(accent || '') ? [accent.toLowerCase(), deepen(accent)] : icon.SITES[iconSite(service)].accent; |
| 112 | } |
| 113 | |
| 114 | module.exports = { has, url, isCard, path: cardPath, accentOf, iconSite, pageOf, PAGES, domainOf, domainForHost, DOMAINS, DIR }; |
| 115 |