diff --git a/package.json b/package.json index bd99d03f..563a4612 100644 --- a/package.json +++ b/package.json @@ -15,12 +15,15 @@ "bundle:diff": "node --experimental-strip-types scripts/bundle-diff.ts", "bundle:save": "node --experimental-strip-types scripts/bundle-diff.ts --save", "lighthouse": "node --experimental-strip-types scripts/lighthouse.ts", - "lighthouse:mobile": "node --experimental-strip-types scripts/lighthouse.ts --mobile" + "lighthouse:mobile": "node --experimental-strip-types scripts/lighthouse.ts --mobile", + "og:probe": "node --experimental-strip-types scripts/probe-og.ts" }, "dependencies": { "@iconify-json/lucide": "^1.2.102", "@iconify-json/simple-icons": "^1.2.77", "@monaco-editor/react": "^4.7.0", + "@takumi-rs/image-response": "0.62.8", + "@takumi-rs/wasm": "0.62.8", "@tanstack/react-query": "^5.99.0", "@vercel/analytics": "^1.6.1", "@vercel/speed-insights": "^1.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6eb114c..14670c0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,12 @@ importers: '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@takumi-rs/image-response': + specifier: 0.62.8 + version: 0.62.8 + '@takumi-rs/wasm': + specifier: 0.62.8 + version: 0.62.8 '@tanstack/react-query': specifier: ^5.99.0 version: 5.99.0(react@19.2.5) diff --git a/public/og-docs.png b/public/og-docs.png index e64b7158..2524f7ff 100644 Binary files a/public/og-docs.png and b/public/og-docs.png differ diff --git a/scripts/download-og.sh b/scripts/download-og.sh new file mode 100644 index 00000000..c10ce203 --- /dev/null +++ b/scripts/download-og.sh @@ -0,0 +1,152 @@ +#!/bin/bash +# Download OG images for all docs routes + +OUT="/Users/achal/Desktop/og-preview" +BASE="http://localhost:5173" +mkdir -p "$OUT" + +download_og() { + local title="$1" + local section="$2" + local subsection="$3" + local filename="$4" + + local url="${BASE}/api/og?title=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$title'))")§ion=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$section'))")" + if [ -n "$subsection" ]; then + url="${url}&subsection=$(python3 -c "import urllib.parse; print(urllib.parse.quote('$subsection'))")" + fi + + curl -s -o "${OUT}/${filename}.webp" "$url" + echo " Downloaded: ${filename}" +} + +echo "=== Landing pages (static) ===" +curl -s -o "${OUT}/landing--home.png" "${BASE}/og-docs.png" +echo " Downloaded: landing--home.png" + +echo "" +echo "=== Top-level section pages (TEMPO + section) ===" +download_og "Getting Funds on Tempo" "BUILD" "" "build--getting-funds" +download_og "Create & Use Accounts" "BUILD" "" "build--use-accounts" +download_og "Make Payments" "BUILD" "" "build--payments" +download_og "Issue Stablecoins" "BUILD" "" "build--issuance" +download_og "Exchange Stablecoins" "BUILD" "" "build--stablecoin-dex" +download_og "Make Machine Payments" "BUILD" "" "build--machine-payments" +download_og "Use Tempo Transactions" "BUILD" "" "build--tempo-transaction" +download_og "Using Tempo with AI" "BUILD" "" "build--ai" + +download_og "Overview" "INTEGRATE" "" "integrate--overview" +download_og "Connect to the Network" "INTEGRATE" "" "integrate--connection-details" +download_og "Get Testnet Faucet Funds" "INTEGRATE" "" "integrate--faucet" +download_og "EVM Differences" "INTEGRATE" "" "integrate--evm-compatibility" +download_og "Predeployed Contracts" "INTEGRATE" "" "integrate--predeployed-contracts" +download_og "Token List Registry" "INTEGRATE" "" "integrate--tokenlist" +download_og "Wallet Developers" "INTEGRATE" "" "integrate--wallet-developers" +download_og "Contract Verification" "INTEGRATE" "" "integrate--verify-contracts" + +download_og "Overview" "PROTOCOL" "" "protocol--overview" +download_og "Blockspace" "PROTOCOL" "" "protocol--blockspace" +download_og "Fees" "PROTOCOL" "" "protocol--fees" +download_og "TIPs" "PROTOCOL" "" "protocol--tips" + +download_og "Overview" "SDKs" "" "sdk--overview" +download_og "TypeScript" "SDKs" "" "sdk--typescript" +download_og "Go" "SDKs" "" "sdk--go" +download_og "Foundry" "SDKs" "" "sdk--foundry" +download_og "Python" "SDKs" "" "sdk--python" +download_og "Rust" "SDKs" "" "sdk--rust" + +download_og "Overview" "CLI" "" "cli--overview" +download_og "Wallet" "CLI" "" "cli--wallet" +download_og "Request" "CLI" "" "cli--request" +download_og "Download" "CLI" "" "cli--download" +download_og "Node" "CLI" "" "cli--node" + +download_og "Overview" "ECOSYSTEM" "" "ecosystem--overview" +download_og "Bridges & Exchanges" "ECOSYSTEM" "" "ecosystem--bridges" +download_og "Wallets" "ECOSYSTEM" "" "ecosystem--wallets" + +download_og "Overview" "LEARN" "" "learn--overview" +download_og "Partners" "LEARN" "" "learn--partners" +download_og "Stablecoins" "LEARN" "" "learn--stablecoins" + +echo "" +echo "=== Subsection pages (section + subsection) ===" +download_og "Embed Passkey Accounts" "BUILD" "ACCOUNTS" "build--accounts--embed-passkeys" +download_og "Connect to Wallets" "BUILD" "ACCOUNTS" "build--accounts--connect-to-wallets" +download_og "Add Funds to Your Balance" "BUILD" "ACCOUNTS" "build--accounts--add-funds" + +download_og "Send a Payment" "BUILD" "PAYMENTS" "build--payments--send" +download_og "Accept a Payment" "BUILD" "PAYMENTS" "build--payments--accept" +download_og "Attach a Transfer Memo" "BUILD" "PAYMENTS" "build--payments--transfer-memos" +download_og "Pay Fees in Any Stablecoin" "BUILD" "PAYMENTS" "build--payments--pay-fees" +download_og "Sponsor User Fees" "BUILD" "PAYMENTS" "build--payments--sponsor-user-fees" +download_og "Send Parallel Transactions" "BUILD" "PAYMENTS" "build--payments--parallel" + +download_og "Create a Stablecoin" "BUILD" "ISSUANCE" "build--issuance--create" +download_og "Mint Stablecoins" "BUILD" "ISSUANCE" "build--issuance--mint" +download_og "Use Your Stablecoin for Fees" "BUILD" "ISSUANCE" "build--issuance--use-for-fees" +download_og "Distribute Rewards" "BUILD" "ISSUANCE" "build--issuance--distribute-rewards" +download_og "Manage Your Stablecoin" "BUILD" "ISSUANCE" "build--issuance--manage" + +download_og "Managing Fee Liquidity" "BUILD" "EXCHANGE" "build--exchange--fee-liquidity" +download_og "Executing Swaps" "BUILD" "EXCHANGE" "build--exchange--executing-swaps" +download_og "View the Orderbook" "BUILD" "EXCHANGE" "build--exchange--orderbook" +download_og "Providing Liquidity" "BUILD" "EXCHANGE" "build--exchange--providing-liquidity" + +download_og "Client Quickstart" "BUILD" "MACHINE PAY" "build--machine-pay--client" +download_og "Agent Quickstart" "BUILD" "MACHINE PAY" "build--machine-pay--agent" +download_og "Server Quickstart" "BUILD" "MACHINE PAY" "build--machine-pay--server" +download_og "Accept One-Time Payments" "BUILD" "MACHINE PAY" "build--machine-pay--one-time" +download_og "Accept Pay-as-you-go Payments" "BUILD" "MACHINE PAY" "build--machine-pay--payg" +download_og "Accept Streamed Payments" "BUILD" "MACHINE PAY" "build--machine-pay--streamed" + +download_og "Specification" "PROTOCOL" "TIP-20" "protocol--tip20--spec" +download_og "Overview" "PROTOCOL" "TIP-20" "protocol--tip20--overview" +download_og "Specification" "PROTOCOL" "TIP-20 REWARDS" "protocol--tip20-rewards--spec" +download_og "Specification" "PROTOCOL" "TIP-403" "protocol--tip403--spec" +download_og "Specification" "PROTOCOL" "FEES" "protocol--fees--spec" +download_og "Fee AMM" "PROTOCOL" "FEES" "protocol--fees--fee-amm" +download_og "Specification" "PROTOCOL" "TRANSACTIONS" "protocol--transactions--spec" +download_og "EIP-4337 Comparison" "PROTOCOL" "TRANSACTIONS" "protocol--transactions--eip4337" +download_og "EIP-7702 Comparison" "PROTOCOL" "TRANSACTIONS" "protocol--transactions--eip7702" +download_og "Account Keychain Precompile Specification" "PROTOCOL" "TRANSACTIONS" "protocol--transactions--keychain" +download_og "Payment Lane Specification" "PROTOCOL" "BLOCKSPACE" "protocol--blockspace--payment-lane" +download_og "Consensus and Finality" "PROTOCOL" "BLOCKSPACE" "protocol--blockspace--consensus" +download_og "Specification" "PROTOCOL" "DEX" "protocol--dex--spec" +download_og "Quote Tokens" "PROTOCOL" "DEX" "protocol--dex--quote-tokens" +download_og "Executing Swaps" "PROTOCOL" "DEX" "protocol--dex--executing-swaps" +download_og "Providing Liquidity" "PROTOCOL" "DEX" "protocol--dex--providing-liquidity" +download_og "DEX Balance" "PROTOCOL" "DEX" "protocol--dex--balance" + +download_og "Handlers" "SDKs" "TYPESCRIPT" "sdk--typescript--handlers" +download_og "compose" "SDKs" "TYPESCRIPT" "sdk--typescript--compose" +download_og "feePayer" "SDKs" "TYPESCRIPT" "sdk--typescript--feePayer" +download_og "keyManager" "SDKs" "TYPESCRIPT" "sdk--typescript--keyManager" + +download_og "System Requirements" "BUILD" "NODE" "node--system-requirements" +download_og "Installation" "BUILD" "NODE" "node--installation" +download_og "Running an RPC Node" "BUILD" "NODE" "node--rpc" +download_og "Running a Validator" "BUILD" "NODE" "node--validator" +download_og "Operating Your Validator" "BUILD" "NODE" "node--operate-validator" +download_og "Network Upgrades and Releases" "BUILD" "NODE" "node--network-upgrades" + +download_og "Remittances" "LEARN" "USE CASES" "learn--use-cases--remittances" +download_og "Global Payouts" "LEARN" "USE CASES" "learn--use-cases--global-payouts" +download_og "Payroll" "LEARN" "USE CASES" "learn--use-cases--payroll" +download_og "Embedded Finance" "LEARN" "USE CASES" "learn--use-cases--embedded-finance" +download_og "Tokenized Deposits" "LEARN" "USE CASES" "learn--use-cases--tokenized-deposits" +download_og "Microtransactions" "LEARN" "USE CASES" "learn--use-cases--microtransactions" +download_og "Agentic Commerce" "LEARN" "USE CASES" "learn--use-cases--agentic-commerce" + +download_og "Native Stablecoins" "LEARN" "TEMPO" "learn--tempo--native-stablecoins" +download_og "Modern Transactions" "LEARN" "TEMPO" "learn--tempo--modern-transactions" +download_og "Performance" "LEARN" "TEMPO" "learn--tempo--performance" +download_og "Onchain FX" "LEARN" "TEMPO" "learn--tempo--fx" +download_og "Privacy" "LEARN" "TEMPO" "learn--tempo--privacy" +download_og "Machine Payments" "LEARN" "TEMPO" "learn--tempo--machine-payments" + +echo "" +echo "=== Done ===" +ls -1 "$OUT" | wc -l +echo "images saved to $OUT" diff --git a/scripts/probe-og.ts b/scripts/probe-og.ts new file mode 100644 index 00000000..c0424ff8 --- /dev/null +++ b/scripts/probe-og.ts @@ -0,0 +1,290 @@ +/** + * OG image coverage probe. + * + * Walks every route file under `src/pages/`, replicates the `ogImageUrl` + * logic from `vocs.config.ts` to derive each route's (section, subsection), + * collapses to the unique buckets, then probes each bucket against a + * running server and verifies it returns a valid image response. + * + * Two layers of validation: + * 1. Mapping coverage — fails if any route hits the auto-uppercase + * fallback path instead of an explicit map entry. + * 2. Runtime — every static landing image, every dynamic bucket, and a + * handful of title edge cases must return 2xx with image/* content-type. + * + * Usage (against a `pnpm dev` server): + * VITE_USE_HTTP=true pnpm dev --port 5181 + * PREVIEW_URL=http://localhost:5181 pnpm og:probe + * + * Keep the maps below in sync with vocs.config.ts. + */ + +import { readdirSync, statSync, writeFileSync } from 'node:fs' +import { join, relative } from 'node:path' + +const PREVIEW = process.env.PREVIEW_URL ?? 'https://tempo-docs-k6tznt1fw-tempoxyz.vercel.app' +const PAGES_DIR = 'src/pages' + +const sectionMap: Record = { + quickstart: 'INTEGRATE', + guide: 'BUILD', + protocol: 'PROTOCOL', + sdk: 'SDKs', + cli: 'CLI', + ecosystem: 'ECOSYSTEM', + learn: 'LEARN', + wallet: 'WALLET', + accounts: 'ACCOUNTS', +} + +const subsectionMap: Record = { + 'use-accounts': 'ACCOUNTS', + payments: 'PAYMENTS', + issuance: 'ISSUANCE', + 'stablecoin-dex': 'EXCHANGE', + 'machine-payments': 'MACHINE PAY', + 'tempo-transaction': 'TRANSACTIONS', + tip20: 'TIP-20', + 'tip20-rewards': 'REWARDS', + tip403: 'TIP-403', + fees: 'FEES', + transactions: 'TRANSACTIONS', + blockspace: 'BLOCKSPACE', + exchange: 'DEX', + tips: 'TIPS', + node: 'NODE', + typescript: 'TYPESCRIPT', + go: 'GO', + foundry: 'FOUNDRY', + python: 'PYTHON', + rust: 'RUST', + stablecoins: 'STABLECOINS', + 'use-cases': 'USE CASES', + tempo: 'TEMPO', + zones: 'ZONES', + 'private-zones': 'PRIVATE ZONES', + upgrades: 'UPGRADES', + api: 'API', + guides: 'GUIDES', + rpc: 'RPC', + server: 'SERVER', + wagmi: 'WAGMI', +} + +const LANDING = new Set(['/', '/learn', '/changelog']) + +function deriveOgParams(path: string) { + if (LANDING.has(path)) return { kind: 'static' as const, url: '/og-docs.png' } + const segments = path.split('/').filter(Boolean) + const firstSeg = segments[0] || '' + const secondSeg = segments[1] || '' + const sectionMapped = firstSeg in sectionMap + const section = sectionMap[firstSeg] || firstSeg.toUpperCase().replace(/-/g, ' ') + const subsectionMapped = segments.length >= 3 && secondSeg in subsectionMap + const subsection = + segments.length >= 3 && subsectionMap[secondSeg] + ? subsectionMap[secondSeg] + : segments.length >= 3 + ? secondSeg.toUpperCase().replace(/-/g, ' ') + : '' + return { + kind: 'dynamic' as const, + section, + subsection, + sectionMapped, + subsectionMapped, + firstSeg, + secondSeg, + hasSubsection: segments.length >= 3, + } +} + +function fileToRoute(filePath: string) { + const rel = relative(PAGES_DIR, filePath).replace(/\.(mdx|md|tsx)$/, '') + if (rel.startsWith('_')) return null + if (rel.includes('/_')) return null + if (rel === 'index') return '/' + if (rel.endsWith('/index')) return `/${rel.slice(0, -'/index'.length)}` + return `/${rel}` +} + +function walk(dir: string): string[] { + const out: string[] = [] + for (const entry of readdirSync(dir)) { + const full = join(dir, entry) + const s = statSync(full) + if (s.isDirectory()) out.push(...walk(full)) + else if (/\.(mdx|md|tsx)$/.test(entry)) out.push(full) + } + return out +} + +const allFiles = walk(PAGES_DIR) +const routes = allFiles + .map(fileToRoute) + .filter((r): r is string => !!r) + .filter((r) => !r.startsWith('/_api/')) + +const buckets = new Map() +const fallbackSections = new Map() +const fallbackSubsections = new Map() +let staticCount = 0 +for (const route of routes) { + const og = deriveOgParams(route) + if (og.kind === 'static') { + staticCount++ + continue + } + const key = `${og.section}::${og.subsection}` + if (!buckets.has(key)) { + buckets.set(key, { route, section: og.section, subsection: og.subsection }) + } + if (!og.sectionMapped) { + if (!fallbackSections.has(og.firstSeg)) fallbackSections.set(og.firstSeg, []) + fallbackSections.get(og.firstSeg)?.push(route) + } + if (og.hasSubsection && !og.subsectionMapped) { + if (!fallbackSubsections.has(og.secondSeg)) fallbackSubsections.set(og.secondSeg, []) + fallbackSubsections.get(og.secondSeg)?.push(route) + } +} + +console.log( + `Discovered ${routes.length} routes, ${staticCount} landing (static), ${buckets.size} unique dynamic OG buckets.\n`, +) + +console.log('=== Mapping coverage check ===') +if (fallbackSections.size === 0 && fallbackSubsections.size === 0) { + console.log(' 100% coverage: every section and subsection has an explicit map entry.\n') +} else { + if (fallbackSections.size > 0) { + console.log(' Sections falling through to auto-uppercase:') + for (const [seg, paths] of fallbackSections) { + console.log( + ` ${seg.padEnd(20)} → "${seg.toUpperCase().replace(/-/g, ' ')}" (${paths.length} routes, e.g. ${paths[0]})`, + ) + } + } + if (fallbackSubsections.size > 0) { + console.log(' Subsections falling through to auto-uppercase:') + for (const [seg, paths] of fallbackSubsections) { + console.log( + ` ${seg.padEnd(20)} → "${seg.toUpperCase().replace(/-/g, ' ')}" (${paths.length} routes, e.g. ${paths[0]})`, + ) + } + } + console.log('') +} + +type ProbeResult = { + label: string + url: string + status: number + ct: string + bytes: number + ms: number + error?: string +} + +async function probe(label: string, url: string): Promise { + const t0 = Date.now() + try { + const r = await fetch(url, { method: 'GET', redirect: 'follow' }) + const ct = r.headers.get('content-type') ?? '' + const buf = await r.arrayBuffer() + const ms = Date.now() - t0 + return { label, url, status: r.status, ct, bytes: buf.byteLength, ms } + } catch (e) { + return { label, url, status: -1, ct: '', bytes: 0, ms: Date.now() - t0, error: String(e) } + } +} + +type AnnotatedResult = ProbeResult & { + kind: 'static' | 'bucket' | 'edge' + section?: string + subsection?: string + sampleRoute?: string + desc?: string +} + +const results: AnnotatedResult[] = [] + +console.log('=== Static landing OG ===') +for (const path of ['/og-docs.png']) { + const r = await probe('static og-docs.png', `${PREVIEW}${path}`) + results.push({ kind: 'static', ...r }) + console.log(` ${r.status} ${r.ct.padEnd(20)} ${r.bytes} bytes ${r.ms}ms ${r.url}`) +} + +console.log('\n=== Dynamic OG (one per (section,subsection) bucket) ===') +const sample = Array.from(buckets.values()) +let i = 0 +for (const b of sample) { + i++ + const params = new URLSearchParams({ + title: 'Sample Title For OG Verification', + description: 'Probe', + section: b.section, + ...(b.subsection ? { subsection: b.subsection } : {}), + }) + const url = `${PREVIEW}/api/og?${params.toString()}` + const r = await probe(`bucket ${b.section}/${b.subsection || '-'} (${b.route})`, url) + results.push({ + kind: 'bucket', + section: b.section, + subsection: b.subsection, + sampleRoute: b.route, + ...r, + }) + console.log( + ` [${String(i).padStart(2)}/${sample.length}] ${r.status} ${r.ct.padEnd(20)} ${String(r.bytes).padStart(7)}B ${String(r.ms).padStart(5)}ms ${b.section}${b.subsection ? `/${b.subsection}` : ''}`, + ) +} + +console.log('\n=== Title edge cases ===') +const edge = [ + { title: 'X', desc: 'one-char' }, + { title: 'API', desc: 'short three-letter' }, + { + title: + 'A truly absurdly long page title that will exercise the balanceLines path with three lines and possible wrapping', + desc: 'very long', + }, + { title: 'Émoji & “smart quotes” — café', desc: 'unicode' }, + { title: 'tip-1017: account abstraction', desc: 'mixed case + colon' }, + { title: '', desc: 'html-ish' }, +] +for (const e of edge) { + const params = new URLSearchParams({ + title: e.title, + description: 'edge', + section: 'PROTOCOL', + subsection: 'TIPS', + }) + const url = `${PREVIEW}/api/og?${params.toString()}` + const r = await probe(`edge:${e.desc}`, url) + results.push({ kind: 'edge', desc: e.desc, ...r }) + console.log( + ` ${r.status} ${r.ct.padEnd(20)} ${String(r.bytes).padStart(7)}B ${String(r.ms).padStart(5)}ms ${e.desc}`, + ) +} + +const failed = results.filter((r) => r.status !== 200 || !r.ct.startsWith('image/')) +console.log(`\n=== Summary ===`) +console.log(`Total probes: ${results.length}`) +console.log(`Failed: ${failed.length}`) +if (failed.length) { + console.log('\nFailures:') + for (const f of failed) console.log(` ${f.kind} ${f.status} ${f.ct} ${f.url}`) +} + +writeFileSync( + 'og-probe-results.json', + JSON.stringify( + { preview: PREVIEW, totalRoutes: routes.length, buckets: buckets.size, results }, + null, + 2, + ), +) +console.log('\nFull results written to og-probe-results.json') +process.exit(failed.length ? 1 : 0) diff --git a/src/pages/_api/api/fonts/HBSet-Light.otf b/src/pages/_api/api/fonts/HBSet-Light.otf new file mode 100644 index 00000000..f1b67673 Binary files /dev/null and b/src/pages/_api/api/fonts/HBSet-Light.otf differ diff --git a/src/pages/_api/api/fonts/Pilat-Regular.otf b/src/pages/_api/api/fonts/Pilat-Regular.otf new file mode 100644 index 00000000..ce2eb763 Binary files /dev/null and b/src/pages/_api/api/fonts/Pilat-Regular.otf differ diff --git a/src/pages/_api/api/og-bg.png b/src/pages/_api/api/og-bg.png new file mode 100644 index 00000000..548f7978 Binary files /dev/null and b/src/pages/_api/api/og-bg.png differ diff --git a/src/pages/_api/api/og.tsx b/src/pages/_api/api/og.tsx index 8fc50bbb..8bdf44ff 100644 --- a/src/pages/_api/api/og.tsx +++ b/src/pages/_api/api/og.tsx @@ -1,89 +1,240 @@ -import { Handler } from 'vocs/server' - -export default function handler(request: Request) { - return Handler.og(({ title, description }) => ( -
-
- {/** biome-ignore lint/a11y/noSvgWithoutTitle: _ */} - - - - - - - -
+import { ImageResponse } from '@takumi-rs/image-response/wasm' +// @ts-expect-error -- vite arraybuffer import +import wasmModule from '@takumi-rs/wasm/takumi_wasm_bg.wasm?arraybuffer' +// @ts-expect-error -- vite arraybuffer import +import hbSetFont from './fonts/HBSet-Light.otf?arraybuffer' +// @ts-expect-error -- vite arraybuffer import +import pilatFont from './fonts/Pilat-Regular.otf?arraybuffer' +// @ts-expect-error -- vite arraybuffer import +import bgImageBuf from './og-bg.png?arraybuffer' + +function getTitleFontSize(_title: string): number { + return 105 +} + +/** + * Split title into balanced lines so no single word is orphaned. + * Finds the word-boundary split closest to the midpoint of the string. + */ +function balanceLines(text: string, fontSize: number): string[] { + const words = text.split(' ') + if (words.length <= 2) return [text] + + const maxWidth = 960 + const avgCharWidth = fontSize * 0.58 + const charsPerLine = Math.floor(maxWidth / avgCharWidth) + + if (text.length <= charsPerLine) return [text] + + const needsThreeLines = text.length > charsPerLine * 2 + + if (needsThreeLines) { + const target = text.length / 3 + let bestI = 0 + let bestJ = 1 + let bestScore = Number.POSITIVE_INFINITY + for (let i = 0; i < words.length - 2; i++) { + const line1 = words.slice(0, i + 1).join(' ') + for (let j = i + 1; j < words.length - 1; j++) { + const line2 = words.slice(i + 1, j + 1).join(' ') + const line3 = words.slice(j + 1).join(' ') + const score = + Math.abs(line1.length - target) + + Math.abs(line2.length - target) + + Math.abs(line3.length - target) + if (score < bestScore) { + bestScore = score + bestI = i + bestJ = j + } + } + } + return [ + words.slice(0, bestI + 1).join(' '), + words.slice(bestI + 1, bestJ + 1).join(' '), + words.slice(bestJ + 1).join(' '), + ] + } + let bestSplit = 0 + let bestDiff = Number.POSITIVE_INFINITY + for (let i = 0; i < words.length - 1; i++) { + const left = words.slice(0, i + 1).join(' ') + const right = words.slice(i + 1).join(' ') + const diff = Math.abs(left.length - right.length) + if (diff < bestDiff) { + bestDiff = diff + bestSplit = i + } + } + + return [words.slice(0, bestSplit + 1).join(' '), words.slice(bestSplit + 1).join(' ')] +} + +export default async function handler(request: Request) { + const url = new URL(request.url) + const title = url.searchParams.get('title') || 'Tempo' + const section = url.searchParams.get('section') || '' + const subsection = url.searchParams.get('subsection') || '' + + const hasSubsection = !!subsection + + const fontSize = getTitleFontSize(title) + + const bgBytes = new Uint8Array(bgImageBuf) + let bgBinary = '' + for (let i = 0; i < bgBytes.length; i++) bgBinary += String.fromCharCode(bgBytes[i]) + const bgUrl = `data:image/png;base64,${btoa(bgBinary)}` + + try { + return new ImageResponse(
-
- {title} -
+ {/** biome-ignore lint/a11y/useAltText: og image */} + - {description && ( + {/* Pill / tag at top center */} + {section && (
- {description.length > 120 ? `${description.slice(0, 120)}...` : description} +
+
+ DOCS +
+
+ {hasSubsection ? ( +
+ {section} + + {subsection} +
+ ) : ( + section + )} +
+
)} -
-
- )).fetch(request) + + {/* Title text */} +
+ {balanceLines(title, fontSize).map((line) => ( +
+ {line} +
+ ))} +
+ + {/* Tempo "T" logo at bottom center */} + {/** biome-ignore lint/a11y/noSvgWithoutTitle: og image */} + + + + , + { + module: wasmModule, + width: 1200, + height: 657, + fonts: [ + { name: 'HBSet', data: hbSetFont, weight: 300, style: 'normal' as const }, + { name: 'Pilat', data: pilatFont, weight: 400, style: 'normal' as const }, + ], + }, + ) + } catch (error) { + console.error(error) + return new Response('Failed to generate OG image', { status: 500 }) + } } diff --git a/src/pages/_root.css b/src/pages/_root.css index ceb612e6..33e84780 100644 --- a/src/pages/_root.css +++ b/src/pages/_root.css @@ -231,7 +231,7 @@ } [data-v-logo] img { - height: 28px; + height: 20px; margin-top: 2px; } diff --git a/vocs.config.ts b/vocs.config.ts index dbdf6988..b7fb2ebc 100644 --- a/vocs.config.ts +++ b/vocs.config.ts @@ -41,10 +41,76 @@ export default defineConfig({ ], }, baseUrl: baseUrl || undefined, - ogImageUrl: (path, { baseUrl } = { baseUrl: '' }) => - path === '/' - ? `${baseUrl}/og-docs.png` - : `${baseUrl}/api/og?title=%title&description=%description`, + ogImageUrl: (path, { baseUrl } = { baseUrl: '' }) => { + const landingPaths = ['/', '/learn', '/changelog'] + if (landingPaths.includes(path)) return `${baseUrl}/og-docs.png` + + const sectionMap: Record = { + quickstart: 'INTEGRATE', + guide: 'BUILD', + protocol: 'PROTOCOL', + sdk: 'SDKs', + cli: 'CLI', + ecosystem: 'ECOSYSTEM', + learn: 'LEARN', + wallet: 'WALLET', + accounts: 'ACCOUNTS', + } + + const subsectionMap: Record = { + 'use-accounts': 'ACCOUNTS', + payments: 'PAYMENTS', + issuance: 'ISSUANCE', + 'stablecoin-dex': 'EXCHANGE', + 'machine-payments': 'MACHINE PAY', + 'tempo-transaction': 'TRANSACTIONS', + tip20: 'TIP-20', + 'tip20-rewards': 'REWARDS', + tip403: 'TIP-403', + fees: 'FEES', + transactions: 'TRANSACTIONS', + blockspace: 'BLOCKSPACE', + exchange: 'DEX', + tips: 'TIPS', + node: 'NODE', + typescript: 'TYPESCRIPT', + go: 'GO', + foundry: 'FOUNDRY', + python: 'PYTHON', + rust: 'RUST', + stablecoins: 'STABLECOINS', + 'use-cases': 'USE CASES', + tempo: 'TEMPO', + zones: 'ZONES', + 'private-zones': 'PRIVATE ZONES', + upgrades: 'UPGRADES', + api: 'API', + guides: 'GUIDES', + rpc: 'RPC', + server: 'SERVER', + wagmi: 'WAGMI', + } + + const segments = path.split('/').filter(Boolean) + const firstSeg = segments[0] || '' + const secondSeg = segments[1] || '' + const section = sectionMap[firstSeg] || firstSeg.toUpperCase().replace(/-/g, ' ') + const subsection = + segments.length >= 3 && subsectionMap[secondSeg] + ? subsectionMap[secondSeg] + : segments.length >= 3 + ? secondSeg.toUpperCase().replace(/-/g, ' ') + : '' + + const params = new URLSearchParams({ + title: '%title', + description: '%description', + section, + ...(subsection ? { subsection } : {}), + }) + + return `${baseUrl}/api/og?${params.toString()}` + }, // TODO: Change back to file paths (`/lockup-light.svg`, `/lockup-dark.svg`) once password protection is removed logoUrl: { light: