#!/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/-.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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[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 => `
${body}
`; const brand = (sub = '') => `
wayBetter${sub ? ` ${esc(sub)}` : ''}
`; 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(`
${brand()}

(FOSS) Everything is an app now.

The apps you use, way better.

A light native window, themes, plugins and fixes for the apps you already use. Free and open source.

`) }, { file: 'x.png', html: page(`
${brand('for X')}

(FOSS) Free & open source · GPL-3.0

X, extended.

Tabs, split panes, every account side by side, themes, plugins and fixes for X's own bugs.

`) }, { file: 'store.png', html: page(`
${featured.map((item, index) => ``).join('')}
${brand('Store')}

(FOSS) One signed catalog

Make every app yours.

${count('theme')} themes and ${count('plugin')} plugins. Read the code, then install in one click.

`) }, ]; 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(`
${brand('Store')}

${item.kind === 'plugin' ? 'Plugin' : 'Theme'} · for ${esc(item.apps.map(app => appInfo(app).site).join(', '))}

${esc(item.name)}

${esc(item.author ? `by ${item.author} · ` : '')}v${esc(item.version)} · free in the wayBetter Store

`), }); } 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/`);