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
9 changes: 5 additions & 4 deletions apps/docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,16 @@ from the same Markdown frontmatter used by the site; new pages receive cards aut
metadata share the same image, title, description, and accessible image text. Canonical URLs and social image URLs
always point to the published GitHub Pages site, including when previewing locally.

- `lib/og-image.tsx` defines the Bloxwap layout, accent colors, and typography.
- `lib/og-image.tsx` defines the GitHub-style card layout: a `section/Title` path headline, description, app icon,
a facts row with the wordmark, and the brand palette as a bottom bar.
- `lib/brand.ts` vendors the official brand geometry from the Bloxwap monorepo's `packages/tokens/src/brand.ts`.
Cards draw the wordmark from these SVG paths. Re-copy the geometry from the monorepo when the marks change.
- `lib/social.ts` defines the published URL and metadata. Update it if the repository or public hostname changes.
- `app/og/[...slug]/route.tsx` generates `/og/index.png`, `/og/docs.png`, and `/og/docs/<slug>.png` during the static build.

Cards use Nunito Black for headlines, Nunito Bold for descriptions, and Space Grotesk Bold for product and category labels.
The static Nunito faces come from the Bloxwap monorepo's `packages/og/assets/`; their license is included as
`public/fonts/Nunito-OG-OFL.txt`. The existing Space Grotesk face retains its `public/fonts/SpaceGrotesk-OFL.txt` notice.
Cards use Nunito Black for page titles and Nunito Bold for the path prefix, description, and facts. The static Nunito
faces come from the Bloxwap monorepo's `packages/og/assets/`; their license is included as
`public/fonts/Nunito-OG-OFL.txt`.
The renderer uses bundled fonts and brand geometry locally; it needs no font CDN, API credentials, or image server after
deployment. Generated PNGs live in `out/` and are included in the GitHub Pages artifact.

Expand Down
17 changes: 6 additions & 11 deletions apps/docs/app/og/[...slug]/route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,19 @@ export function generateStaticParams() {
export async function GET(_request: Request, { params }: { params: Promise<{ slug: string[] }> }) {
const path = `/og/${(await params).slug.join("/")}`;
if (path === socialImagePath("/")) {
return createSocialImage({
title: "Build on Hyperliquid.",
description: homeDescription,
category: "TYPESCRIPT 路 JAVASCRIPT",
home: true,
});
return createSocialImage({ prefix: "bloxwap", title: "hyperliquid", description: homeDescription });
}

const page = source.getPages().find((page) => socialImagePath(page.url) === path);
if (!page) return new Response("Not found", { status: 404 });

return createSocialImage({
prefix: page.url.startsWith("/docs/guides")
? "guides"
: page.url.startsWith("/docs/reference")
? "reference"
: "docs",
title: page.data.title,
description: page.data.description ?? homeDescription,
category: page.url.startsWith("/docs/guides")
? "GUIDES"
: page.url.startsWith("/docs/reference")
? "REFERENCE"
: "DOCUMENTATION",
});
}
222 changes: 116 additions & 106 deletions apps/docs/lib/og-image.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,46 @@
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { ImageResponse } from "next/og";
import { BRAND_GREEN, BRAND_WORDMARK, BRAND_X } from "@/lib/brand";
import { BRAND_GREEN, BRAND_ICON, BRAND_WORDMARK, BRAND_X } from "@/lib/brand";
import { socialImageSize } from "@/lib/social";

// The Bloxwap social card, ported from monorepo/packages/og (og-card.tsx and brand-mark.tsx) and
// laid out like the @bloxwap/chart docs cards: the drawn wordmark with a "路 HYPERLIQUID" product
// label, a category top right, a 24px accent rail, and a Nunito Black headline anchored above the
// description. Colors are sRGB hex for the Satori/resvg renderer.
// The Bloxwap social card, laid out like a GitHub repository card: a "prefix/title" path headline
// and description on the left, the app icon as the avatar top right, a row of facts with the
// wordmark bottom right, and the brand palette as the language bar along the bottom edge.
// Colors are sRGB hex for the Satori/resvg renderer.
const BRAND = {
black: "#0A0A0A",
text: "#F5F7FA",
muted: "rgba(245,247,250,0.62)",
dot: "rgba(245,247,250,0.28)",
category: "#A1A1A1",
};

/** Accent palette in the monorepo's order; the seeded hash below indexes it. */
const ACCENT_PALETTE = [BRAND_GREEN, "#35B5FF", "#B300FF", "#FF479C", "#FFFB38"] as const;
/** The brand palette in the monorepo's order, drawn as equal segments of the bottom bar. */
const PALETTE = [BRAND_GREEN, "#35B5FF", "#B300FF", "#FF479C", "#FFFB38"] as const;

const assets = Promise.all([
readFile(join(process.cwd(), "public/fonts/Nunito-Bold.ttf")),
readFile(join(process.cwd(), "public/fonts/Nunito-Black.ttf")),
readFile(join(process.cwd(), "public/fonts/SpaceGrotesk-Bold.ttf")),
]);

/** Deterministic FNV-1a, as monorepo/packages/og/src/accent.ts: a page keeps its color. */
function accentFor(seed: string): string {
let hash = 0x811c9dc5;
for (let i = 0; i < seed.length; i += 1) hash = Math.imul(hash ^ seed.charCodeAt(i), 0x01000193);
return ACCENT_PALETTE[(hash >>> 0) % ACCENT_PALETTE.length];
}

/** Headline size, tracking and leading step down as the title grows (monorepo HEADLINE_TIERS). */
/** Headline size and leading step down as the title grows; the path prefix counts toward it. */
const HEADLINE_TIERS = [
{ max: 14, size: 148, tracking: -5, line: 0.94 },
{ max: 28, size: 116, tracking: -4, line: 0.95 },
{ max: 46, size: 92, tracking: -3, line: 0.96 },
{ max: 66, size: 72, tracking: -2, line: 0.98 },
{ max: Number.POSITIVE_INFINITY, size: 58, tracking: -1.5, line: 1 },
{ max: 24, size: 76, line: 1.08 },
{ max: 44, size: 64, line: 1.1 },
{ max: Number.POSITIVE_INFINITY, size: 54, line: 1.12 },
] as const;

/** Lucide icon paths (24px grid, 2px round strokes) for the facts row. */
const ICONS = {
code: ["m18 16 4-4-4-4", "m6 8-4 4 4 4", "m14.5 4-5 16"],
scale: [
"M12 3v18",
"m19 8 3 8a5 5 0 0 1-6 0zV7",
"M3 7h1a17 17 0 0 0 8-2 17 17 0 0 0 8 2h1",
"m5 8 3 8a5 5 0 0 1-6 0zV7",
"M7 21h10",
],
} as const;

/** Clamp by code point so a surrogate pair is never split. */
function clamp(text: string, max: number): string {
const points = [...text.trim()];
Expand Down Expand Up @@ -96,130 +96,140 @@ function Wordmark({ capHeight }: { capHeight: number }) {
);
}

/** The app icon (bloxwap-icon-green.svg) in the avatar slot. */
function AppIcon({ size }: { size: number }) {
return (
<svg
width={size}
height={size}
viewBox={BRAND_ICON.viewBox}
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Bloxwap"
>
<rect width="100" height="100" rx={BRAND_ICON.radius} fill={BRAND_GREEN} />
<g fill="none" stroke={BRAND.black} strokeWidth={BRAND_ICON.strokeWidth} strokeLinecap="round">
{BRAND_ICON.paths.map((d) => (
<path key={d} d={d} />
))}
</g>
</svg>
);
}

function Fact({ icon, label }: { icon: keyof typeof ICONS; label: string }) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<svg width={30} height={30} viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" role="img" aria-label={icon}>
<g fill="none" stroke={BRAND.muted} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
{ICONS[icon].map((d) => (
<path key={d} d={d} />
))}
</g>
</svg>
<span style={{ display: "flex" }}>{label}</span>
</div>
);
}

type Card = {
/** Muted path segment before the slash, e.g. "bloxwap" or "guides". */
prefix: string;
title: string;
description: string;
/** Top-right category, e.g. "GUIDES". */
category: string;
/** The landing card: green accent and the two-line landing headline. */
home?: boolean;
};

/** Rendered at build time; GitHub Pages serves the resulting PNG without a server. */
export async function createSocialImage({ title, description, category, home = false }: Card) {
const [bold, black, display] = await assets;
const headline = clamp(title, 90);
const tier = HEADLINE_TIERS.find((t) => headline.length <= t.max) ?? HEADLINE_TIERS[HEADLINE_TIERS.length - 1];
const accent = home ? BRAND_GREEN : accentFor(headline);
const body = clamp(description, 180);
export async function createSocialImage({ prefix, title, description }: Card) {
const [bold, black] = await assets;
const headline = clamp(title, 72);
const tier =
HEADLINE_TIERS.find((t) => prefix.length + 1 + headline.length <= t.max) ??
HEADLINE_TIERS[HEADLINE_TIERS.length - 1];
// Satori wraps flex items, not text runs, so each word is its own item after the prefix.
const words = headline.split(/\s+/);

return new ImageResponse(
<div
style={{
display: "flex",
position: "relative",
flexDirection: "column",
width: "100%",
height: "100%",
background: BRAND.black,
color: BRAND.text,
fontFamily: "Nunito",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
position: "absolute",
top: 0,
left: 0,
width: 24,
height: "100%",
background: accent,
}}
/>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
padding: "62px 72px 54px 78px",
flex: 1,
padding: "80px 80px 56px",
}}
>
<div style={{ display: "flex", alignItems: "flex-end", justifyContent: "space-between" }}>
{/* Eyebrow: the Space Grotesk caps sit on the wordmark's baseline (monorepo BrandEyebrow). */}
<div style={{ display: "flex", alignItems: "flex-end", gap: 14 }}>
<Wordmark capHeight={26} />
<div style={{ display: "flex", justifyContent: "space-between", gap: 64 }}>
<div style={{ display: "flex", flexDirection: "column", flex: 1 }}>
<div
style={{
display: "flex",
flexWrap: "wrap",
fontSize: tier.size,
lineHeight: tier.line,
letterSpacing: -1,
}}
>
<span style={{ display: "flex", fontWeight: 700, color: BRAND.muted }}>{prefix}/</span>
{words.map((word, i) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: a title may repeat a word.
key={i}
style={{
display: "flex",
fontWeight: 900,
marginRight: i < words.length - 1 ? tier.size * 0.26 : 0,
}}
>
{word}
</span>
))}
</div>
<div
style={{
display: "flex",
gap: 14,
fontFamily: "Space Grotesk",
fontSize: 26,
lineHeight: 1,
marginBottom: -4,
marginTop: 28,
fontSize: 28,
fontWeight: 700,
lineHeight: 1.35,
color: BRAND.muted,
}}
>
<span style={{ display: "flex", color: BRAND.dot }}>路</span>
<span style={{ display: "flex", color: accent, letterSpacing: 6 }}>HYPERLIQUID</span>
{clamp(description, 150)}
</div>
</div>
<span
style={{
display: "flex",
fontFamily: "Space Grotesk",
fontSize: 18,
lineHeight: 1,
letterSpacing: 3,
color: BRAND.category,
}}
>
{category}
</span>
<AppIcon size={168} />
</div>
<div style={{ display: "flex", flexDirection: "column", flex: 1, justifyContent: "flex-end" }}>
<div
style={{
display: "flex",
flexDirection: "column",
maxWidth: 1040,
fontSize: home ? 116 : tier.size,
fontWeight: 900,
letterSpacing: home ? -4 : tier.tracking,
lineHeight: home ? 0.95 : tier.line,
}}
>
{/* Satori lays out a fragment's children on one row, so the home lines are separate divs. */}
{home
? ["Build on", "Hyperliquid."].map((line) => (
<div key={line} style={{ display: "flex" }}>
{line}
</div>
))
: headline}
</div>
<div
style={{
display: "flex",
maxWidth: 960,
marginTop: 34,
fontSize: 28,
fontWeight: 700,
lineHeight: 1.3,
color: BRAND.muted,
}}
>
{body}
<div style={{ display: "flex", flex: 1 }} />
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ display: "flex", gap: 44, fontSize: 26, fontWeight: 700 }}>
<Fact icon="code" label="TypeScript" />
<Fact icon="scale" label="MIT license" />
</div>
<Wordmark capHeight={30} />
</div>
</div>
<div style={{ display: "flex", height: 16 }}>
{PALETTE.map((color) => (
<div key={color} style={{ display: "flex", flex: 1, background: color }} />
))}
</div>
</div>,
{
...socialImageSize,
fonts: [
{ name: "Nunito", data: bold, weight: 700, style: "normal" },
{ name: "Nunito", data: black, weight: 900, style: "normal" },
{ name: "Space Grotesk", data: display, weight: 700, style: "normal" },
],
},
);
Expand Down
Loading