184 lines
7.0 KiB
JavaScript
184 lines
7.0 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
|
|
*
|
|
* 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 is the same rect geometry as `components/Moai.astro`.
|
|
*/
|
|
import { createRequire } from 'node:module';
|
|
import { mkdir, 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 master: the moai on the site's background, generous padding. */
|
|
const iconHtml = `<!doctype html><html><head><meta charset="utf-8"><style>
|
|
* { margin: 0; padding: 0; }
|
|
body {
|
|
width: 512px; height: 512px; background: #0E0D0B;
|
|
display: flex; align-items: center; justify-content: center;
|
|
}
|
|
</style></head><body>${moaiSvg(320)}</body></html>`;
|
|
|
|
/**
|
|
* 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 });
|
|
|
|
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');
|
|
|
|
await page.setViewportSize({ width: 512, height: 512 });
|
|
await page.setContent(iconHtml, { waitUntil: 'load' });
|
|
const master = await page.screenshot({ type: 'png' });
|
|
|
|
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, so flatten onto the brand ground.
|
|
await sharp(master)
|
|
.resize(180, 180)
|
|
.flatten({ background: '#0E0D0B' })
|
|
.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 same mark, at any size, for browsers that take it.
|
|
await writeFile(path.join(publicDir, 'favicon.svg'), moaiSvg(11));
|
|
console.log('favicon.svg');
|
|
} finally {
|
|
await browser.close();
|
|
}
|