/** * Generates the social card and the icon set into `public/`. * * Run by hand after a brand change; the output is committed. This is deliberately not * wired into `pnpm build` — the site must build on a machine with no browser, and these * files change roughly never. * * node scripts/make-assets.mjs * node scripts/make-assets.mjs --no-card # icon set only, needs no browser * * Playwright and sharp are not dependencies of this workspace. Both arrive transitively * under `astro` (which ships sharp for its image pipeline) and are resolved through it, * the same trick `bones.mjs` relies on for its capture browser. If that ever stops * working, `pnpm dlx playwright@1` and `pnpm dlx sharp-cli` do the same job. * * The card is drawn in a real browser rather than assembled by hand because it uses the * site's own fonts and tokens: a social card that does not look like the site is worth * less than no card. The moai on the card is the same rect geometry as * `components/Moai.astro`. The icon set is a different mark: the pixel cashu nut carried * over from the old site (`src/assets/cashu-logo.png`), composed with sharp alone. */ import { createRequire } from 'node:module'; import { mkdir, readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const publicDir = path.join(root, 'public'); const fontsDir = path.join(root, 'src', 'assets', 'fonts'); // astro's entry point; requiring from there reaches what pnpm hoisted under it. const require = createRequire(import.meta.resolve('astro')); const { chromium } = require('playwright'); const sharp = require('sharp'); /* The pixel moai, identical to components/Moai.astro. viewBox is 11x13. */ const MOAI_RECTS = [ [2, 0, 7, 1, '#C9BFA4'], [1, 1, 9, 2, '#C9BFA4'], [1, 3, 2, 7, '#A89B7C'], [3, 3, 6, 7, '#C9BFA4'], [3, 4, 2, 1, '#5C5340'], [7, 4, 2, 1, '#5C5340'], [5, 5, 1, 3, '#A89B7C'], [2, 10, 8, 2, '#A89B7C'], ]; const moaiSvg = (width) => `` + MOAI_RECTS.map( ([x, y, w, h, fill]) => ``, ).join('') + ``; const fontFace = (family, weight, file) => ` @font-face { font-family: '${family}'; font-style: normal; font-weight: ${weight}; src: url('file://${path.join(fontsDir, file)}') format('woff2'); }`; /* The 1200x630 card. Design tokens copied from styles/global.css. */ const cardHtml = `
${moaiSvg(40)}Cashumints.space

Find a Cashu mint you can trust

Every mint on the Nostr network, with community reviews you can verify yourself.

cashumints.space
`; /* The icon source: the old site's pixel cashu nut, 1000x1000 with transparent margins. */ const logoPath = path.join(root, 'src', 'assets', 'cashu-logo.png'); /** * A 32x32 ICO wrapping a PNG payload. * * sharp cannot write ICO and the format needs no library for this case: the container * is a 6-byte directory header, one 16-byte entry, then the PNG bytes verbatim. Worth * having because bare `/favicon.ico` requests from crawlers and feed readers still * happen, and a 404 there is noise in the log for no reason. */ function icoFromPng(png) { const header = Buffer.alloc(6); header.writeUInt16LE(0, 0); // reserved header.writeUInt16LE(1, 2); // type: icon header.writeUInt16LE(1, 4); // one image const entry = Buffer.alloc(16); entry[0] = 32; // width entry[1] = 32; // height entry[2] = 0; // palette size: 0 means truecolour entry[3] = 0; // reserved entry.writeUInt16LE(1, 4); // colour planes entry.writeUInt16LE(32, 6); // bits per pixel entry.writeUInt32LE(png.length, 8); entry.writeUInt32LE(header.length + entry.length, 12); return Buffer.concat([header, entry, png]); } await mkdir(publicDir, { recursive: true }); /* * The icon master: the nut trimmed and centred on the brand ground, at the footprint the * moai used to occupy (glyph ≈ 380px tall in the 512 tile). Content this size also stays * inside the circle a maskable icon is cropped to. */ const glyph = await sharp(logoPath).trim().resize(384, 384, { fit: 'inside' }).png().toBuffer(); const master = await sharp({ create: { width: 512, height: 512, channels: 4, background: '#0E0D0B' }, }) .composite([{ input: glyph, gravity: 'centre' }]) .png() .toBuffer(); for (const [name, size] of [ ['icon-512.png', 512], ['icon-192.png', 192], ['favicon-32.png', 32], ]) { await sharp(master).resize(size, size).png({ compressionLevel: 9 }).toFile(path.join(publicDir, name)); console.log(name); } // iOS ignores transparency and composites on white; the master's ground is already opaque. await sharp(master) .resize(180, 180) .png({ compressionLevel: 9 }) .toFile(path.join(publicDir, 'apple-touch-icon.png')); console.log('apple-touch-icon.png'); const png32 = await sharp(master).resize(32, 32).png({ compressionLevel: 9 }).toBuffer(); await writeFile(path.join(publicDir, 'favicon.ico'), icoFromPng(png32)); console.log('favicon.ico'); // The standalone SVG favicon: the nut itself, transparent like the old site served it. // The PNG rides inside the SVG so one source file covers every size; `pixelated` keeps // the pixel art crisp if anything scales it up. const logoB64 = (await readFile(logoPath)).toString('base64'); await writeFile( path.join(publicDir, 'favicon.svg'), `` + ``, ); console.log('favicon.svg'); if (!process.argv.includes('--no-card')) { const browser = await chromium.launch(); try { const page = await browser.newPage({ viewport: { width: 1200, height: 630 }, deviceScaleFactor: 1, }); await page.setContent(cardHtml, { waitUntil: 'load' }); await page.evaluate(() => document.fonts.ready); await page.screenshot({ path: path.join(publicDir, 'og.png') }); console.log('og.png'); } finally { await browser.close(); } }