wayBetter for X

This commit is contained in:
CRTkafa 2026-10-01 06:01:13 +07:00
commit 2f1e35ba20
720 changed files with 147465 additions and 0 deletions

107
scripts/store/og-images.mjs Normal file
View file

@ -0,0 +1,107 @@
#!/usr/bin/env node
// Link-preview pictures (Open Graph / Twitter card, 1200×630) for the site, drawn with the site's
// own look and a headless Chromium (Playwright, a dev dependency of apps/desktop):
// apps/web/public/og/home.png, x.png, store.png the pages
// apps/web/public/og/items/<kind>-<slug>.jpg one per Store item in the published catalog
// The site build uses an item's picture when the file exists and the Store's otherwise
// (apps/web/store-pages.ts), so this is optional after adding an item: run it, commit the new file.
//
// node scripts/store/og-images.mjs draw what is missing
// node scripts/store/og-images.mjs --all redraw everything
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const PUBLIC = join(ROOT, 'apps/web/public');
const OUT = join(PUBLIC, 'og');
const all = process.argv.includes('--all');
const { chromium } = createRequire(join(ROOT, 'apps/desktop/package.json'))('playwright');
const { itemSlugs } = await import('../../apps/web/seo.ts');
const { appInfo } = await import('../../apps/web/site.config.ts');
const esc = value => String(value).replace(/[&<>"]/g, ch => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[ch]);
const dataUrl = (path, type) => `data:${type};base64,${readFileSync(path).toString('base64')}`;
const mark = dataUrl(join(PUBLIC, 'brand/wb-mark-simple.svg'), 'image/svg+xml');
const catalog = JSON.parse(readFileSync(join(PUBLIC, 'store/catalog.json'), 'utf8'));
const preview = item => dataUrl(join(PUBLIC, 'store', item.preview.slice('https://waybetter.fyi/store/'.length)), item.preview.endsWith('.png') ? 'image/png' : 'image/svg+xml');
const page = body => `<!doctype html><html><head><meta charset="utf-8">
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400..800&family=Geist+Mono:wght@500&family=Instrument+Serif:ital@1&display=block" rel="stylesheet">
<style>
* { box-sizing: border-box; margin: 0; }
html, body { width: 1200px; height: 630px; }
body { position: relative; overflow: hidden; background: #07080c; color: #eef0f7; font-family: Geist, 'Segoe UI', sans-serif; -webkit-font-smoothing: antialiased; }
.wash { position: absolute; inset: 0; background:
radial-gradient(46% 60% at 82% 38%, rgba(120, 86, 255, 0.34), transparent 70%),
radial-gradient(36% 48% at 60% 8%, rgba(34, 211, 238, 0.16), transparent 70%),
radial-gradient(34% 44% at 4% 0%, rgba(79, 107, 255, 0.26), transparent 70%); }
.dots { position: absolute; inset: 0; background-image: radial-gradient(rgba(142, 149, 171, 0.2) 1px, transparent 1px); background-size: 28px 28px;
-webkit-mask-image: radial-gradient(70% 80% at 70% 30%, #000 10%, transparent 75%); }
.in { position: absolute; inset: 0; padding: 64px 72px; display: flex; flex-direction: column; }
.brand { display: flex; align-items: center; gap: 16px; font-size: 30px; font-weight: 650; letter-spacing: -0.02em; }
.brand img { height: 40px; }
.brand span { color: #8e95ab; font-weight: 500; }
.kicker { margin-top: auto; font: 500 19px 'Geist Mono', monospace; letter-spacing: 0.08em; text-transform: uppercase; color: #8e95ab; }
.kicker b { color: #3ddc97; font-weight: 500; }
h1 { margin-top: 18px; font-size: 104px; line-height: 0.94; font-weight: 700; letter-spacing: -0.052em; max-width: 640px; }
h1.long { font-size: 78px; }
h1.longer { font-size: 62px; }
em { white-space: nowrap; font-family: 'Instrument Serif', serif; font-style: italic; font-weight: 400; font-size: 1.1em; letter-spacing: -0.025em;
background: linear-gradient(128deg, #7cc8ff 0%, #1d9bf0 42%, #7856ff 100%); -webkit-background-clip: text; color: transparent; padding-right: 0.08em; }
.sub { margin-top: 22px; font-size: 27px; line-height: 1.35; color: #c3c7d4; max-width: 600px; letter-spacing: -0.01em; }
.line { position: absolute; left: 0; right: 0; bottom: 0; height: 6px; background: linear-gradient(90deg, #4f6bff, #22d3ee 55%, #a855f7); }
.art img { position: absolute; border-radius: 16px; box-shadow: 0 0 0 1px rgba(238, 240, 247, 0.14), 0 40px 90px -20px rgba(0, 0, 0, 0.9), 0 70px 130px -40px rgba(79, 107, 255, 0.6); }
.a1 { width: 420px; right: 150px; top: 60px; transform: rotate(-7deg); }
.a2 { width: 420px; right: -40px; top: 170px; transform: rotate(5deg); }
.a3 { width: 420px; right: 96px; top: 310px; transform: rotate(-1.5deg); }
.one { width: 520px; right: 56px; top: 150px; transform: rotate(3deg); }
.glow { position: absolute; right: 40px; top: 120px; width: 560px; height: 420px; border-radius: 50%; filter: blur(70px); opacity: 0.75;
background: radial-gradient(50% 60% at 30% 60%, rgba(79, 107, 255, 0.6), transparent 70%), radial-gradient(50% 60% at 80% 40%, rgba(168, 85, 247, 0.5), transparent 70%); }
.wide h1 { max-width: none; }
.wide .sub { max-width: 900px; }
</style></head><body><div class="wash"></div><div class="dots"></div>${body}<div class="line"></div></body></html>`;
const brand = (sub = '') => `<div class="brand"><img src="${mark}" alt="">wayBetter${sub ? ` <span>${esc(sub)}</span>` : ''}</div>`;
const featured = (() => {
const path = join(ROOT, 'store/featured.json');
const keys = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : [];
const picked = keys.map(key => catalog.items.find(item => item.apps.some(app => `${app}/${item.kind}/${item.id}` === key))).filter(Boolean);
return (picked.length >= 3 ? picked : catalog.items).slice(0, 3);
})();
const count = kind => catalog.items.filter(item => item.kind === kind).length;
const jobs = [
{ file: 'home.png', html: page(`<div class="in wide">${brand()}<p class="kicker"><b>(FOSS)</b> Everything is an app now.</p><h1>The apps you use, <em>way better.</em></h1><p class="sub">A light native window, themes, plugins and fixes for the apps you already use. Free and open source.</p></div>`) },
{ file: 'x.png', html: page(`<div class="in wide">${brand('for X')}<p class="kicker"><b>(FOSS)</b> Free &amp; open source · GPL-3.0</p><h1>X, <em>extended.</em></h1><p class="sub">Tabs, split panes, every account side by side, themes, plugins and fixes for X's own bugs.</p></div>`) },
{ file: 'store.png', html: page(`<div class="glow"></div><div class="art">${featured.map((item, index) => `<img class="a${index + 1}" src="${preview(item)}" alt="">`).join('')}</div><div class="in">${brand('Store')}<p class="kicker"><b>(FOSS)</b> One signed catalog</p><h1>Make every app <em>yours.</em></h1><p class="sub">${count('theme')} themes and ${count('plugin')} plugins. Read the code, then install in one click.</p></div>`) },
];
const slugs = itemSlugs(catalog.items);
const wanted = new Set();
for (const item of catalog.items) {
const file = `items/${item.kind}-${slugs.get(item)}.jpg`;
wanted.add(file);
const size = item.name.length > 22 ? 'longer' : item.name.length > 12 ? 'long' : '';
jobs.push({
file, jpeg: true,
html: page(`<div class="glow"></div><div class="art"><img class="one" src="${preview(item)}" alt=""></div><div class="in">${brand('Store')}<p class="kicker">${item.kind === 'plugin' ? 'Plugin' : 'Theme'} · for ${esc(item.apps.map(app => appInfo(app).site).join(', '))}</p><h1 class="${size}" style="max-width: 560px">${esc(item.name)}</h1><p class="sub" style="max-width: 520px">${esc(item.author ? `by ${item.author} · ` : '')}v${esc(item.version)} · free in the wayBetter Store</p></div>`),
});
}
mkdirSync(join(OUT, 'items'), { recursive: true });
// A picture whose item left the catalog goes too.
for (const name of readdirSync(join(OUT, 'items'))) if (!wanted.has(`items/${name}`)) rmSync(join(OUT, 'items', name));
const todo = jobs.filter(job => all || !existsSync(join(OUT, job.file)));
if (todo.length) {
const browser = await chromium.launch();
const tab = await (await browser.newContext({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 1 })).newPage();
for (const job of todo) {
await tab.setContent(job.html, { waitUntil: 'networkidle' });
await tab.evaluate(() => document.fonts.ready);
await tab.screenshot({ path: join(OUT, job.file), ...(job.jpeg ? { type: 'jpeg', quality: 86 } : { type: 'png' }) });
}
await browser.close();
}
console.log(`og images: ${todo.length} drawn, ${jobs.length - todo.length} kept → apps/web/public/og/`);