Files
CashuMints.space/web/scripts/make-assets.mjs
T
michilisandCursor 47e6537dde Expand i18n locales and improve reviews UI
Add many new language packs with RTL support, refresh brand assets, and harden review rendering with tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-21 05:06:58 +02:00

199 lines
7.8 KiB
JavaScript

/**
* 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) =>
`<svg width="${width}" height="${Math.round((width / 11) * 13)}" viewBox="0 0 11 13" ` +
`shape-rendering="crispEdges" xmlns="http://www.w3.org/2000/svg">` +
MOAI_RECTS.map(
([x, y, w, h, fill]) => `<rect x="${x}" y="${y}" width="${w}" height="${h}" fill="${fill}"/>`,
).join('') +
`</svg>`;
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 = `<!doctype html><html><head><meta charset="utf-8"><style>
${fontFace('Space Grotesk', 700, 'space-grotesk-latin.woff2')}
${fontFace('Inter', 400, 'inter-latin.woff2')}
${fontFace('JetBrains Mono', 400, 'jetbrains-mono-latin.woff2')}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
width: 1200px; height: 630px; background: #0E0D0B; color: #EDE8DC;
font-family: 'Inter', sans-serif; display: flex; flex-direction: column;
justify-content: center; padding: 0 88px; position: relative; overflow: hidden;
}
/* The same warm glow the hero carries, kept subtle so text stays legible. */
body::after {
content: ''; position: absolute; inset: auto -200px -320px auto;
width: 720px; height: 720px; border-radius: 50%;
background: radial-gradient(circle, rgba(240,169,59,.16), transparent 68%);
}
.brand { display: flex; align-items: center; gap: 18px; margin-bottom: 40px; }
.brand span {
font-family: 'Space Grotesk', sans-serif; font-weight: 700; font-size: 34px;
letter-spacing: -.01em;
}
h1 {
font-family: 'Space Grotesk', sans-serif; font-weight: 700; font-size: 76px;
line-height: 1.06; letter-spacing: -.03em; max-width: 15ch;
}
h1 em { font-style: normal; color: #F0A93B; }
p { margin-top: 28px; font-size: 27px; line-height: 1.45; color: #9A927E; max-width: 34ch; }
.rule { margin-top: 44px; width: 132px; height: 5px; background: #F0A93B; border-radius: 3px; }
.host {
position: absolute; bottom: 54px; right: 88px;
font-family: 'JetBrains Mono', monospace; font-size: 21px; color: #6B6555;
}
</style></head><body>
<div class="brand">${moaiSvg(40)}<span>Cashumints.space</span></div>
<h1>Find a Cashu mint <em>you can trust</em></h1>
<p>Every mint on the Nostr network, with community reviews you can verify yourself.</p>
<div class="rule"></div>
<div class="host">cashumints.space</div>
</body></html>`;
/* 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'),
`<svg xmlns="http://www.w3.org/2000/svg" width="1000" height="1000" viewBox="0 0 1000 1000">` +
`<image width="1000" height="1000" style="image-rendering:pixelated" ` +
`href="data:image/png;base64,${logoB64}"/></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();
}
}