mirror of
https://github.com/hanzoai/rfc.git
synced 2026-10-30 18:23:14 +00:00
351 lines
9.2 KiB
JavaScript
351 lines
9.2 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Generate static OG images for the LPs docs site
|
|
* Run: node scripts/generate-og.mjs
|
|
*/
|
|
|
|
import satori from 'satori';
|
|
import { Resvg } from '@resvg/resvg-js';
|
|
import { writeFileSync, mkdirSync, readFileSync } from 'fs';
|
|
import { createRequire } from 'module';
|
|
import { dirname, join } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const publicDir = join(__dirname, '..', 'public');
|
|
|
|
// Ensure public directory exists
|
|
mkdirSync(publicDir, { recursive: true });
|
|
|
|
// Zen, the same family the site renders. The browser gets the woff2 @hanzo/design
|
|
// declares; satori reads only TTF/OTF, so here the cuts come from @hanzo/font,
|
|
// where the faces are authored. Resolved through node_modules ā never copied in.
|
|
const require = createRequire(import.meta.url);
|
|
const zen = (cut) => readFileSync(require.resolve(`@hanzo/font/dist/fonts/zen-sans/Zen-${cut}.ttf`));
|
|
|
|
const fonts = [
|
|
{ name: 'Zen', data: zen('Regular'), weight: 400, style: 'normal' },
|
|
{ name: 'Zen', data: zen('SemiBold'), weight: 600, style: 'normal' },
|
|
];
|
|
|
|
// OG Image component (as React-like object)
|
|
const ogImageJsx = {
|
|
type: 'div',
|
|
props: {
|
|
style: {
|
|
height: '100%',
|
|
width: '100%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: '#000',
|
|
position: 'relative',
|
|
},
|
|
children: [
|
|
// Gradient overlay
|
|
{
|
|
type: 'div',
|
|
props: {
|
|
style: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
background: 'radial-gradient(ellipse at top, rgba(255,255,255,0.08) 0%, transparent 60%)',
|
|
},
|
|
},
|
|
},
|
|
// Content
|
|
{
|
|
type: 'div',
|
|
props: {
|
|
style: {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
zIndex: 1,
|
|
},
|
|
children: [
|
|
// Logo container
|
|
{
|
|
type: 'div',
|
|
props: {
|
|
style: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: 80,
|
|
height: 80,
|
|
borderRadius: 20,
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
backgroundColor: 'rgba(255,255,255,0.03)',
|
|
marginBottom: 32,
|
|
},
|
|
children: [
|
|
// Triangle logo (downward-pointing per @luxfi/logo)
|
|
{
|
|
type: 'svg',
|
|
props: {
|
|
width: 40,
|
|
height: 40,
|
|
viewBox: '0 0 100 100',
|
|
children: {
|
|
type: 'path',
|
|
props: {
|
|
d: 'M50 85 L15 25 L85 25 Z',
|
|
fill: 'white',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
// Title
|
|
{
|
|
type: 'div',
|
|
props: {
|
|
style: {
|
|
fontSize: 72,
|
|
fontWeight: 600,
|
|
color: 'white',
|
|
letterSpacing: '-0.02em',
|
|
marginBottom: 16,
|
|
},
|
|
children: 'Lux Proposals',
|
|
},
|
|
},
|
|
// Tagline
|
|
{
|
|
type: 'div',
|
|
props: {
|
|
style: {
|
|
fontSize: 28,
|
|
color: 'rgba(255,255,255,0.5)',
|
|
maxWidth: 700,
|
|
textAlign: 'center',
|
|
lineHeight: 1.4,
|
|
},
|
|
children: 'Open, community-driven standards for the Lux Network ecosystem',
|
|
},
|
|
},
|
|
// Stats badge
|
|
{
|
|
type: 'div',
|
|
props: {
|
|
style: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
marginTop: 40,
|
|
padding: '12px 24px',
|
|
borderRadius: 100,
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
backgroundColor: 'rgba(255,255,255,0.03)',
|
|
},
|
|
children: [
|
|
// Dot
|
|
{
|
|
type: 'div',
|
|
props: {
|
|
style: {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 4,
|
|
backgroundColor: 'white',
|
|
},
|
|
},
|
|
},
|
|
// Stats text
|
|
{
|
|
type: 'span',
|
|
props: {
|
|
style: { fontSize: 18, color: 'rgba(255,255,255,0.7)' },
|
|
children: '177 proposals',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
// Bottom branding
|
|
{
|
|
type: 'div',
|
|
props: {
|
|
style: {
|
|
position: 'absolute',
|
|
bottom: 40,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 8,
|
|
fontSize: 16,
|
|
color: 'rgba(255,255,255,0.4)',
|
|
},
|
|
children: 'lps.lux.network',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
// Twitter image (slightly different dimensions)
|
|
const twitterImageJsx = {
|
|
type: 'div',
|
|
props: {
|
|
style: {
|
|
height: '100%',
|
|
width: '100%',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: '#000',
|
|
position: 'relative',
|
|
},
|
|
children: [
|
|
// Gradient
|
|
{
|
|
type: 'div',
|
|
props: {
|
|
style: {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
background: 'radial-gradient(ellipse at top, rgba(255,255,255,0.08) 0%, transparent 60%)',
|
|
},
|
|
},
|
|
},
|
|
// Content
|
|
{
|
|
type: 'div',
|
|
props: {
|
|
style: {
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
zIndex: 1,
|
|
},
|
|
children: [
|
|
// Logo
|
|
{
|
|
type: 'div',
|
|
props: {
|
|
style: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
width: 72,
|
|
height: 72,
|
|
borderRadius: 18,
|
|
border: '1px solid rgba(255,255,255,0.1)',
|
|
backgroundColor: 'rgba(255,255,255,0.03)',
|
|
marginBottom: 28,
|
|
},
|
|
children: [
|
|
{
|
|
type: 'svg',
|
|
props: {
|
|
width: 36,
|
|
height: 36,
|
|
viewBox: '0 0 100 100',
|
|
children: {
|
|
type: 'path',
|
|
props: {
|
|
d: 'M50 85 L15 25 L85 25 Z',
|
|
fill: 'white',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
// Title
|
|
{
|
|
type: 'div',
|
|
props: {
|
|
style: {
|
|
fontSize: 64,
|
|
fontWeight: 600,
|
|
color: 'white',
|
|
letterSpacing: '-0.02em',
|
|
marginBottom: 12,
|
|
},
|
|
children: 'Lux Proposals',
|
|
},
|
|
},
|
|
// Tagline
|
|
{
|
|
type: 'div',
|
|
props: {
|
|
style: {
|
|
fontSize: 24,
|
|
color: 'rgba(255,255,255,0.5)',
|
|
maxWidth: 600,
|
|
textAlign: 'center',
|
|
},
|
|
children: 'Open standards for the Lux Network ecosystem',
|
|
},
|
|
},
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
async function generateImage(jsx, width, height, outputPath) {
|
|
console.log(`Generating ${outputPath}...`);
|
|
|
|
const svg = await satori(jsx, {
|
|
width,
|
|
height,
|
|
fonts,
|
|
});
|
|
|
|
const resvg = new Resvg(svg, {
|
|
fitTo: {
|
|
mode: 'width',
|
|
value: width,
|
|
},
|
|
});
|
|
|
|
const pngData = resvg.render();
|
|
const pngBuffer = pngData.asPng();
|
|
|
|
writeFileSync(outputPath, pngBuffer);
|
|
console.log(` ā Generated ${outputPath} (${pngBuffer.length} bytes)`);
|
|
}
|
|
|
|
async function main() {
|
|
console.log('Generating OG images...\n');
|
|
|
|
// Generate OG image (1200x630)
|
|
await generateImage(
|
|
ogImageJsx,
|
|
1200,
|
|
630,
|
|
join(publicDir, 'og.png')
|
|
);
|
|
|
|
// Generate Twitter image (1200x600)
|
|
await generateImage(
|
|
twitterImageJsx,
|
|
1200,
|
|
600,
|
|
join(publicDir, 'twitter.png')
|
|
);
|
|
|
|
console.log('\nā All OG images generated successfully!');
|
|
}
|
|
|
|
main().catch(console.error);
|