Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ The current baseline includes:
- Wallet UI branding assets
- Minimal placeholder content for the migration phase

## Social previews

Docs pages now get prerendered social cards generated from each entry's title and description.

- Starlight route middleware normalizes page-level `og:title`, `og:description`, `twitter:title`, and `twitter:description` values from each docs entry.
- Build-time Astro endpoints under `src/pages/og/` render per-doc PNG cards with `sharp`, which keeps the deployed Worker simple because image rendering happens during the build, not at request time.
- The shared `public/og.png` remains as the site-wide fallback for any non-doc route metadata.

## Deployment

The site targets Cloudflare Workers through Astro's Cloudflare adapter and Wrangler.
Expand All @@ -43,7 +51,7 @@ Why this path:

- it preserves the existing Starlight docs behavior and Astro redirects
- it keeps the current temporary live URL and noindex posture intact
- it is the simplest deploy target that still leaves room for future server-side features such as OG image generation
- it supports prerendered per-page OG image assets without adding runtime rendering complexity to the Worker

Deployment ownership is Cloudflare-side:

Expand Down
57 changes: 54 additions & 3 deletions astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,27 @@ import { defineConfig } from 'astro/config';
import starlight from '@astrojs/starlight';
import tailwindcss from '@tailwindcss/vite';
import { redirects } from './src/redirects.mjs';
import {
defaultSocialImageAlt,
defaultSocialImagePath,
siteDescription,
siteName,
siteUrl,
} from './src/site-meta.mjs';
import { socialImageHeight, socialImageWidth } from './src/og-image';
import { docsSidebar } from './src/sidebar.mjs';

export default defineConfig({
site: 'https://wallet-ui-astro.wallet-ui.workers.dev',
site: siteUrl,
output: 'server',
adapter: cloudflare({
imageService: 'compile',
prerenderEnvironment: 'node',
}),
integrations: [
starlight({
title: 'Wallet UI',
description: 'Wallet UI is the modern UI for the Wallet Standard.',
title: siteName,
description: siteDescription,
favicon: '/favicon.ico',
logo: {
src: './src/assets/wallet-ui.png',
Expand All @@ -33,6 +41,48 @@ export default defineConfig({
content: 'noindex, nofollow',
},
},
{
tag: 'meta',
attrs: {
property: 'og:image',
content: new URL(defaultSocialImagePath, siteUrl).toString(),
},
},
{
tag: 'meta',
attrs: {
property: 'og:image:alt',
content: defaultSocialImageAlt,
},
},
{
tag: 'meta',
attrs: {
property: 'og:image:width',
content: String(socialImageWidth),
},
},
{
tag: 'meta',
attrs: {
property: 'og:image:height',
content: String(socialImageHeight),
},
},
{
tag: 'meta',
attrs: {
name: 'twitter:image',
content: new URL(defaultSocialImagePath, siteUrl).toString(),
},
},
{
tag: 'meta',
attrs: {
name: 'twitter:image:alt',
content: defaultSocialImageAlt,
},
},
{
tag: 'script',
content:
Expand All @@ -46,6 +96,7 @@ export default defineConfig({
}),
],
customCss: ['./src/styles/global.css'],
routeMiddleware: './src/starlight-route-data.ts',
}),
],
redirects,
Expand Down
Binary file modified public/og.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
23 changes: 23 additions & 0 deletions src/og-image-pages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getCollection } from 'astro:content';
import {
getDocsSocialImagePath,
normalizeDocsSlug,
type DocsSocialImagePage,
} from './og-image';
import { siteDescription } from './site-meta.mjs';

export async function getDocsSocialImagePages(): Promise<DocsSocialImagePage[]> {
const entries = await getCollection('docs', ({ data }) => {
return import.meta.env.MODE !== 'production' || data.draft === false;
});

return entries.map((entry) => {
const slug = normalizeDocsSlug(entry.id);
return {
description: entry.data.description ?? siteDescription,
path: getDocsSocialImagePath(slug),
slug,
title: entry.data.title,
};
});
}
171 changes: 171 additions & 0 deletions src/og-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import sharp from 'sharp';
import { siteDescription, siteName } from './site-meta.mjs';

export const socialImageWidth = 1200;
export const socialImageHeight = 630;
const maxTitleLines = 3;
const maxDescriptionLines = 3;
const walletUiLogoDataUrlPromise = readFile(join(process.cwd(), 'src/assets/wallet-ui.png')).then(
(buffer) => `data:image/png;base64,${buffer.toString('base64')}`
);

export interface DocsSocialImagePage {
[key: string]: string;
description: string;
path: string;
slug: string;
title: string;
}

export function getDocsSocialImagePath(slug: string): string {
return slug ? `/og/${slug}.png` : '/og/index.png';
}

export function getSocialImageAlt(title: string): string {
return `${formatSocialTitle(title)} social preview`;
}

export function formatSocialTitle(title: string): string {
return title === siteName ? siteName : `${title} | ${siteName}`;
}

export async function renderSocialImage(page: Pick<DocsSocialImagePage, 'description' | 'slug' | 'title'>) {
const walletUiLogoDataUrl = await walletUiLogoDataUrlPromise;
const title = formatSocialTitle(page.title);
const description = page.description || siteDescription;
const sectionLabel = getSectionLabel(page.slug);
const hrefLabel = page.slug ? `wallet-ui.dev/${page.slug}` : 'wallet-ui.dev';
const titleLines = wrapText(title, 28, maxTitleLines);
const descriptionLines = wrapText(description, 48, maxDescriptionLines);
const titleText = renderLines(titleLines, 116, 236, 68, '#eff6ff', '700');
const descriptionStartY = 236 + titleLines.length * 78 + 18;
const descriptionText = renderLines(
descriptionLines,
116,
descriptionStartY,
34,
'#dbe7f5',
'400'
);
const descriptionBlockHeight = descriptionLines.length * 44;
const footerY = Math.max(492, Math.min(530, descriptionStartY + descriptionBlockHeight + 44));
const svg = `<?xml version="1.0" encoding="UTF-8"?>
<svg width="${socialImageWidth}" height="${socialImageHeight}" viewBox="0 0 ${socialImageWidth} ${socialImageHeight}" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="bg" x1="0" y1="630" x2="1200" y2="0" gradientUnits="userSpaceOnUse">
<stop stop-color="#C6F4E7" />
<stop offset="0.48" stop-color="#4E87F8" />
<stop offset="1" stop-color="#8E52FF" />
</linearGradient>
<radialGradient id="washA" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(146 548) rotate(-21.801) scale(580.128 329.623)">
<stop stop-color="#FFFFFF" stop-opacity="0.34" />
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0" />
</radialGradient>
<radialGradient id="washB" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(1012 52) rotate(135.198) scale(408.352 265.987)">
<stop stop-color="#FFFFFF" stop-opacity="0.18" />
<stop offset="1" stop-color="#FFFFFF" stop-opacity="0" />
</radialGradient>
<pattern id="grid" x="0" y="0" width="48" height="48" patternUnits="userSpaceOnUse">
<path d="M48 0H0V48" stroke="rgba(255,255,255,0.14)" stroke-width="1"/>
</pattern>
</defs>
<rect width="${socialImageWidth}" height="${socialImageHeight}" fill="url(#bg)" />
<rect width="${socialImageWidth}" height="${socialImageHeight}" fill="url(#grid)" opacity="0.58" />
<rect width="${socialImageWidth}" height="${socialImageHeight}" fill="rgba(8, 14, 28, 0.66)" />
<circle cx="146" cy="548" r="332" fill="url(#washA)" />
<circle cx="1012" cy="52" r="248" fill="url(#washB)" />
<text x="116" y="122" fill="#E8F1FF" font-family="Arial, sans-serif" font-size="22" font-weight="600">Wallet UI Docs</text>
<text x="312" y="122" fill="#F8FBFF" font-family="Arial, sans-serif" font-size="22" font-weight="600">${escapeXml(sectionLabel)}</text>
${titleText}
${descriptionText}
<text x="116" y="${footerY}" fill="#D4DEEC" font-family="Arial, sans-serif" font-size="24" font-weight="500">${escapeXml(
hrefLabel
)}</text>
<rect x="1000" y="462" width="80" height="80" rx="20" fill="rgba(255,255,255,0.12)" stroke="rgba(255,255,255,0.18)" />
<image x="1000" y="462" width="80" height="80" href="${walletUiLogoDataUrl}" preserveAspectRatio="xMidYMid meet" />
</svg>`;

return sharp(Buffer.from(svg)).png().toBuffer();
}

export function normalizeDocsSlug(slug: string): string {
if (!slug || slug === 'index' || slug === '/') return '';
return slug.endsWith('/index') ? slug.slice(0, -6) : slug;
}

function getSectionLabel(slug: string): string {
if (!slug) return 'Overview';
const [section] = slug.split('/');
return section
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}

function wrapText(value: string, maxCharsPerLine: number, maxLines: number): string[] {
const words = value.trim().split(/\s+/).filter(Boolean);
if (!words.length) return [''];

const lines: string[] = [];
let currentLine = '';
let didTruncate = false;

for (const word of words) {
const nextLine = currentLine ? `${currentLine} ${word}` : word;
if (nextLine.length <= maxCharsPerLine) {
currentLine = nextLine;
continue;
}

if (currentLine) lines.push(currentLine);
currentLine = word;

if (lines.length === maxLines - 1) {
didTruncate = true;
break;
}
}

if (lines.length < maxLines && currentLine) {
lines.push(currentLine);
}

if (didTruncate) {
const lastLine = lines[lines.length - 1] ?? '';
lines[lines.length - 1] = truncateLine(lastLine, maxCharsPerLine);
}

return lines.slice(0, maxLines);
}

function truncateLine(value: string, maxCharsPerLine: number): string {
if (value.length <= maxCharsPerLine - 1) return `${value}...`;
return `${value.slice(0, Math.max(0, maxCharsPerLine - 4)).trimEnd()}...`;
}

function renderLines(
lines: string[],
x: number,
startY: number,
lineHeight: number,
fill: string,
fontWeight: '400' | '500' | '600' | '700'
) {
return lines
.map(
(line, index) =>
`<text x="${x}" y="${startY + index * lineHeight}" fill="${fill}" font-family="Arial, sans-serif" font-size="${lineHeight}" font-weight="${fontWeight}">${escapeXml(line)}</text>`
)
.join('');
}

function escapeXml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
27 changes: 27 additions & 0 deletions src/pages/og/[...slug].png.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { APIRoute, GetStaticPaths } from 'astro';
import { renderSocialImage, type DocsSocialImagePage } from '../../og-image';
import { getDocsSocialImagePages } from '../../og-image-pages';

export const prerender = true;

export const getStaticPaths: GetStaticPaths = async () => {
const pages = await getDocsSocialImagePages();

return pages
.filter((page) => page.slug)
.map((page) => ({
params: { slug: page.slug },
props: page,
}));
};

export const GET: APIRoute = async ({ props }) => {
const image = await renderSocialImage(props as DocsSocialImagePage);

return new Response(new Uint8Array(image), {
headers: {
'Cache-Control': 'public, max-age=31536000, immutable',
'Content-Type': 'image/png',
},
});
};
22 changes: 22 additions & 0 deletions src/pages/og/index.png.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { APIRoute } from 'astro';
import { renderSocialImage } from '../../og-image';
import { getDocsSocialImagePages } from '../../og-image-pages';

export const prerender = true;

export const GET: APIRoute = async () => {
const page = (await getDocsSocialImagePages()).find((entry) => entry.slug === '');

if (!page) {
return new Response('Missing docs index social image data.', { status: 500 });
}

const image = await renderSocialImage(page);

return new Response(new Uint8Array(image), {
headers: {
'Cache-Control': 'public, max-age=31536000, immutable',
'Content-Type': 'image/png',
},
});
};
5 changes: 5 additions & 0 deletions src/site-meta.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const siteUrl = 'https://wallet-ui-astro.wallet-ui.workers.dev';
export const siteName = 'Wallet UI';
export const siteDescription = 'The wallet layer for Solana apps on web and mobile.';
export const defaultSocialImagePath = '/og.png';
export const defaultSocialImageAlt = 'Wallet UI documentation social card';
Loading
Loading