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
33 changes: 33 additions & 0 deletions api/_lib/badge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import { renderBadge, textWidth } from './badge';
import { FLAME_TIERS } from '../../src/utils/flame-tiers';
import type { SharedProfile } from './profile-card';

const profile = { userId: 'u', name: 'Nan <script>', username: 'nannndev', avatarUrl: null, runs: 212, bestWpm: 112.4, bestLanguage: 'C++ & Rust', avgWpm: 90, avgAccuracy: 97, bestStreak: 31, topLanguage: 'TypeScript', division: { name: 'Gold II', color: '#ffd700' }, trend: [], badges: { earned: 3, top: [] } } as SharedProfile;

describe('README badges', () => {
it('draws the card with speed, division and Kap, escaping text', () => {
const svg = renderBadge({ profile, kap: { tier: FLAME_TIERS[2], look: { eyes: 'shades' } } });
expect(svg.startsWith('<svg xmlns="http://www.w3.org/2000/svg" width="440" height="120"')).toBe(true);
expect(svg).toContain('>112.4<');
expect(svg).toContain('Gold II');
expect(svg).toContain('best in C++ &amp; Rust 路 212 runs 路 31-day streak');
expect(svg).toContain('Nan &lt;script&gt; on Codey');
expect(svg).not.toContain('<script');
// Kap nested inline, so no outside requests.
expect(svg).toContain('<svg x="17" y="13"');
expect(svg).not.toMatch(/https?:\/\/(?!www\.w3\.org)/);
});

it('has a shields-sized flat style and a placeholder for new players', () => {
const flat = renderBadge({ profile, style: 'flat' });
expect(flat).toContain('height="20"');
expect(flat).toContain('112.4 WPM 路 Gold II');
expect(renderBadge({ profile: null })).toContain('Typing real code');
expect(renderBadge({ profile: null, style: 'flat' })).toContain('typing real code');
});

it('estimates wider text for longer strings', () => {
expect(textWidth('112.4 WPM 路 Platinum III', 11)).toBeGreaterThan(textWidth('98 WPM', 11));
});
});
118 changes: 118 additions & 0 deletions api/_lib/badge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { SharedProfile } from './profile-card.js';
import { kapSvg, type KapLook } from '../../src/utils/kap-art.js';
import type { FlameTier } from '../../src/utils/flame-tiers.js';
import { formatWpm } from './og-image.js';

/**
* README badges: /b/<userId> (or /api/og/<userId>?kind=badge) returns an SVG
* a developer can put on their GitHub profile. Two styles: `card` (default,
* with their Kap) and `flat` (a shields.io-sized pill). GitHub proxies README
* images through camo, which blocks outside fonts and scripts, so everything
* here is plain shapes and system fonts.
*/

export type BadgeStyle = 'card' | 'flat';
export type BadgeTheme = 'dark' | 'light';

export interface BadgeInput {
profile: SharedProfile | null;
kap?: { tier: FlameTier; look: KapLook } | null;
style?: BadgeStyle;
theme?: BadgeTheme;
}

const escape = (value: string) => value.replace(/[&<>"']/g, (char) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]!);
const SANS = "-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif";
const AMBER = '#f59e0b';

/** Rough text width for a sans-serif face; badges have no font metrics to measure with. */
export function textWidth(text: string, size: number, bold = false): number {
let units = 0;
for (const char of text) {
if (/[0-9]/.test(char)) units += 0.56;
else if (/[ilI.,:;'|!]/.test(char)) units += 0.28;
else if (/[mwMW]/.test(char)) units += 0.84;
else if (/[A-Z]/.test(char)) units += 0.66;
else if (char === ' ') units += 0.28;
else units += 0.54;
}
return Math.ceil(units * size * (bold ? 1.06 : 1));
}

function division(profile: SharedProfile | null) {
return profile?.division ?? null;
}

function cardBadge({ profile, kap, theme = 'dark' }: BadgeInput): string {
const dark = theme === 'dark';
const bg = dark ? '#0f1117' : '#ffffff';
const ink = dark ? '#f4f4f5' : '#18181b';
const muted = dark ? '#a1a1aa' : '#71717a';
const border = dark ? '#27272a' : '#e4e4e7';
const width = 440;
const height = 120;
const div = division(profile);
const hasRuns = Boolean(profile?.runs);

const kapMarkup = kap ? kapSvg(kap.tier, kap.look, 86).replace('<svg ', '<svg x="17" y="13" ') : '';
const pillText = div?.name ?? 'Codey typist';
const pillColor = div?.color ?? AMBER;
// Pale division colours (Gold, Silver) need dark text on a light card.
const pillInk = dark ? pillColor : ink;
const pillWidth = textWidth(pillText, 11, true) + 22;

const wpm = hasRuns ? formatWpm(profile!.bestWpm) : '';
const wpmWidth = textWidth(wpm, 40, true);
const where = profile?.bestLanguage && profile.bestLanguage !== 'Mixed' ? profile.bestLanguage : 'code';
const runs = profile?.runs ?? 0;
const facts = [`best in ${where}`, `${runs.toLocaleString('en-US')} ${runs === 1 ? 'run' : 'runs'}`, profile?.bestStreak ? `${profile.bestStreak}-day streak` : ''].filter(Boolean).join(' 路 ');

const body = hasRuns
? `<text x="128" y="78" font-family="${SANS}" font-size="40" font-weight="800" fill="${ink}">${escape(wpm)}</text>`
+ `<text x="${132 + wpmWidth}" y="78" font-family="${SANS}" font-size="15" font-weight="600" fill="${muted}">wpm</text>`
+ `<text x="128" y="101" font-family="${SANS}" font-size="12" fill="${muted}">${escape(facts)}</text>`
: `<text x="128" y="72" font-family="${SANS}" font-size="20" font-weight="800" fill="${ink}">Typing real code</text>`
+ `<text x="128" y="96" font-family="${SANS}" font-size="12" fill="${muted}">First run coming soon on Codey</text>`;

return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="${escape(hasRuns ? `Codey: ${wpm} WPM best on code${div ? `, ${div.name}` : ''}` : 'Codey typing profile')}">`
+ `<title>${escape(profile ? `${profile.name} on Codey` : 'Codey')}</title>`
+ `<rect x="0.5" y="0.5" width="${width - 1}" height="${height - 1}" rx="16" fill="${bg}" stroke="${border}"/>`
+ `<rect x="10" y="10" width="100" height="100" rx="14" fill="${AMBER}"/>`
+ (kapMarkup || `<text x="60" y="72" text-anchor="middle" font-family="${SANS}" font-size="34" font-weight="800" fill="#18181b">&lt;/&gt;</text>`)
+ `<text x="128" y="32" font-family="${SANS}" font-size="11" font-weight="800" letter-spacing="2" fill="${AMBER}">CODEY</text>`
+ `<rect x="${width - 14 - pillWidth}" y="17" width="${pillWidth}" height="22" rx="11" fill="${dark ? 'none' : `${pillColor}33`}" stroke="${pillColor}" stroke-width="1.5"/>`
+ `<text x="${width - 14 - pillWidth / 2}" y="32" text-anchor="middle" font-family="${SANS}" font-size="11" font-weight="700" fill="${pillInk}">${escape(pillText)}</text>`
+ body
+ `</svg>`;
}

function flatBadge({ profile, theme = 'dark' }: BadgeInput): string {
const div = division(profile);
const label = 'Codey';
const value = profile?.runs ? `${formatWpm(profile.bestWpm)} WPM${div ? ` 路 ${div.name}` : ''}` : 'typing real code';
const font = "Verdana,Geneva,'DejaVu Sans',sans-serif";
// Verdana runs about a fifth wider than the generic estimate, more in bold.
const left = Math.ceil(textWidth(label, 11) * 1.18) + 28;
const right = Math.ceil(textWidth(value, 11, true) * 1.24) + 14;
const width = left + right;
const color = div?.color ?? AMBER;
const labelBg = theme === 'light' ? '#e4e4e7' : '#3f3f46';
const labelInk = theme === 'light' ? '#18181b' : '#ffffff';
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="20" viewBox="0 0 ${width} 20" role="img" aria-label="${escape(`${label}: ${value}`)}">`
+ `<title>${escape(`${label}: ${value}`)}</title>`
+ `<clipPath id="r"><rect width="${width}" height="20" rx="4"/></clipPath>`
+ `<g clip-path="url(#r)"><rect width="${left}" height="20" fill="${labelBg}"/><rect x="${left}" width="${right}" height="20" fill="${color}"/></g>`
// A tiny keycap glyph.
+ `<rect x="6" y="5" width="11" height="10" rx="2.5" fill="${AMBER}"/><rect x="8" y="6.5" width="7" height="5" rx="1.5" fill="#fde68a"/>`
+ `<g font-family="${font}" font-size="11">`
+ `<text x="${left / 2 + 7}" y="14" text-anchor="middle" fill="${labelInk}">${label}</text>`
+ `<text x="${left + right / 2}" y="14" text-anchor="middle" fill="#18181b" font-weight="bold">${escape(value)}</text>`
+ `</g></svg>`;
}

export function renderBadge(input: BadgeInput): string {
return input.style === 'flat' ? flatBadge(input) : cardBadge(input);
}

export const badgeStyle = (value: string): BadgeStyle => (value === 'flat' ? 'flat' : 'card');
export const badgeTheme = (value: string): BadgeTheme => (value === 'light' ? 'light' : 'dark');
14 changes: 10 additions & 4 deletions api/_lib/kap-look.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Client, Users } from 'node-appwrite';
import { APPWRITE } from './appwrite-admin.js';
import { FLAME_TIERS, tierFor } from '../../src/utils/flame-tiers.js';
import { FLAME_TIERS, tierFor, type FlameTier } from '../../src/utils/flame-tiers.js';
import { kapDataUri, sanitizeLook, type KapLook } from '../../src/utils/kap-art.js';

/**
Expand All @@ -23,13 +23,19 @@ async function readPrefs(userId: string): Promise<Prefs> {
return (await new Users(client).getPrefs({ userId })) as Prefs;
}

/** A data URI of the player's Kap; the plain Kap when the look cannot be read. */
export async function playerKap(userId: string, streakDays = 7, width = 200, read: (id: string) => Promise<Prefs> = readPrefs): Promise<string> {
/** The player's Kap: their flame by streak and their Wardrobe look (plain when it cannot be read). */
export async function playerLook(userId: string, streakDays = 7, read: (id: string) => Promise<Prefs> = readPrefs): Promise<{ tier: FlameTier; look: KapLook }> {
let look: KapLook = {};
try {
if (userId) look = lookFromPrefs(await read(userId));
} catch {
// The card still draws, with Kap in his usual amber.
}
return kapDataUri(tierFor(streakDays) ?? FLAME_TIERS[1], look, width);
return { tier: tierFor(streakDays) ?? FLAME_TIERS[1], look };
}

/** A data URI of the player's Kap. */
export async function playerKap(userId: string, streakDays = 7, width = 200, read: (id: string) => Promise<Prefs> = readPrefs): Promise<string> {
const { tier, look } = await playerLook(userId, streakDays, read);
return kapDataUri(tier, look, width);
}
16 changes: 14 additions & 2 deletions api/og/[id].ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { loadSharedRun } from '../_lib/share-card.js';
import { renderChallengeImage, renderProfileImage, renderShareImage } from '../_lib/og-image.js';
import { loadChallenge } from '../_lib/challenge-card.js';
import { loadSharedProfile } from '../_lib/profile-card.js';
import { playerKap } from '../_lib/kap-look.js';
import { playerKap, playerLook } from '../_lib/kap-look.js';
import { badgeStyle, badgeTheme, renderBadge } from '../_lib/badge.js';

interface BinaryResponse {
status: (code: number) => BinaryResponse;
setHeader: (name: string, value: string) => void;
end: (body: Buffer) => void;
end: (body: Buffer | string) => void;
}

const first = (value: string | string[] | undefined) => (Array.isArray(value) ? value[0] : value) ?? '';
Expand All @@ -17,6 +18,7 @@ const first = (value: string | string[] | undefined) => (Array.isArray(value) ?
* GET /api/og/<runId>: the PNG preview for a shared run; runs never change, so it caches hard.
* GET /api/og/<userId>?kind=profile: a player's card, cached briefly since it moves with each run.
* GET /api/og/<challengeId>?kind=challenge: the score to beat and the snippet.
* GET /api/og/<userId>?kind=badge&style=card|flat&theme=dark|light: an SVG README badge (also /b/<userId>).
*/
export default async function handler(req: ApiRequest, res: BinaryResponse) {
const id = first(req.query.id).replace(/\.png$/, '');
Expand All @@ -31,6 +33,16 @@ export default async function handler(req: ApiRequest, res: BinaryResponse) {
res.status(200).end(body);
return;
}
if (first(req.query.kind) === 'badge') {
const profile = isConfigured() ? await loadSharedProfile(id.replace(/\.svg$/, '')) : null;
const style = badgeStyle(first(req.query.style));
const kap = profile && style === 'card' ? await playerLook(profile.userId, profile.bestStreak) : null;
res.setHeader('Content-Type', 'image/svg+xml; charset=utf-8');
// GitHub's image proxy honours this, so a badge catches up within the hour.
res.setHeader('Cache-Control', 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=86400');
res.status(200).end(renderBadge({ profile, kap, style, theme: badgeTheme(first(req.query.theme)) }));
return;
}
if (first(req.query.kind) === 'profile') {
const profile = isConfigured() ? await loadSharedProfile(id) : null;
const image = profile ? renderProfileImage(profile, host, await playerKap(profile.userId, profile.bestStreak)) : renderShareImage(null, host);
Expand Down
3 changes: 3 additions & 0 deletions src/components/ProfileShareDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { ReadmeBadge } from "@/components/social/ReadmeBadge";
import { Check, Copy, Download, LoaderCircle, Share2, UserRound, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { SharePostPanel } from "@/components/SharePostPanel";
Expand Down Expand Up @@ -107,6 +108,8 @@ export function ProfileShareDialog({ target, onClose }: ProfileShareDialogProps)

<SharePostPanel text={text} url={url} hint="Anyone with the link sees the card as its preview. For Instagram stories, download the image and post it there." />

{target.own && <ReadmeBadge userId={target.userId} />}

<div className="flex flex-col-reverse gap-2 p-4 sm:flex-row sm:items-center sm:justify-between">
<Button type="button" variant="ghost" size="sm" onClick={copyCaption} className="text-xs text-muted-foreground hover:text-foreground">
{copiedCaption ? <Check data-icon="inline-start" className="size-3.5 text-green-500" /> : <Copy data-icon="inline-start" className="size-3.5" />}
Expand Down
73 changes: 73 additions & 0 deletions src/components/social/ReadmeBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { useState } from "react";
import { BadgeCheck, Check, Copy } from "lucide-react";
import { Button } from "@/components/ui/button";
import { badgeImageUrl, badgeSnippet, type BadgeStyle, type BadgeTheme } from "@/lib/share-links";
import { cn } from "@/lib/utils";

function Segmented<T extends string>({ value, options, onChange, label }: { value: T; options: [T, string][]; onChange: (value: T) => void; label: string }) {
return (
<div className="inline-flex rounded-lg border bg-background p-0.5" role="radiogroup" aria-label={label}>
{options.map(([id, text]) => (
<button
key={id}
type="button"
role="radio"
aria-checked={value === id}
onClick={() => onChange(id)}
className={cn("rounded-md px-2.5 py-1 text-[11px] font-semibold transition-colors cursor-pointer", value === id ? "bg-foreground text-background" : "text-muted-foreground hover:text-foreground")}
>
{text}
</button>
))}
</div>
);
}

/** "Put this on your GitHub profile": a live badge, its preview and the snippet to paste. */
export function ReadmeBadge({ userId }: { userId: string }) {
const [style, setStyle] = useState<BadgeStyle>("card");
const [theme, setTheme] = useState<BadgeTheme>("auto");
const [copied, setCopied] = useState(false);
const origin = window.location.origin;
const snippet = badgeSnippet(origin, userId, style, theme);
const previews: ("dark" | "light")[] = theme === "auto" ? ["dark", "light"] : [theme];

return (
<section className="border-t px-4 py-4 sm:px-5" aria-labelledby="readme-badge-title">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 id="readme-badge-title" className="flex items-center gap-2 text-sm font-bold"><BadgeCheck className="size-4 text-amber-500" /> Badge for your GitHub README</h3>
<p className="text-[11px] text-muted-foreground">It updates on its own as you practice, and links back to your profile.</p>
</div>
<div className="flex flex-wrap gap-2">
<Segmented label="Badge style" value={style} onChange={setStyle} options={[["card", "Card"], ["flat", "Flat"]]} />
<Segmented label="Badge theme" value={theme} onChange={setTheme} options={[["auto", "Auto"], ["dark", "Dark"], ["light", "Light"]]} />
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 rounded-xl border bg-muted/35 p-3">
{previews.map((tone) => (
<div key={tone} className={cn("rounded-lg p-2", tone === "dark" ? "bg-[#0d1117]" : "bg-white")}>
<img src={badgeImageUrl(origin, userId, style, tone)} alt={`Codey badge, ${tone} theme`} className={style === "card" ? "h-[100px] w-auto" : "h-5 w-auto"} loading="lazy" />
</div>
))}
</div>
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-start">
<pre className="min-w-0 flex-1 overflow-x-auto rounded-lg border bg-background px-3 py-2 font-mono text-[11px] leading-relaxed text-muted-foreground">{snippet}</pre>
<Button
type="button"
variant="outline"
onClick={() => void navigator.clipboard.writeText(snippet).then(() => {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
})}
>
{copied ? <Check data-icon="inline-start" className="text-green-500" /> : <Copy data-icon="inline-start" />}
{copied ? "Copied!" : theme === "auto" ? "Copy HTML" : "Copy Markdown"}
</Button>
</div>
<p className="mt-2 text-[11px] text-muted-foreground">
Paste it into the README of the repo named after your username (github.com/you/you) to show it on your profile.
</p>
</section>
);
}
15 changes: 15 additions & 0 deletions src/lib/share-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,18 @@ describe("profile share links", () => {
expect(profileShareText({ name: "Ada", bestWpm: 0, runs: 0 }, false)).toContain("Ada is practicing");
});
});

describe("README badge snippets", async () => {
const { badgeImageUrl, badgeSnippet } = await import("./share-links");
it("builds the badge URL with only non-default options", () => {
expect(badgeImageUrl("https://codey.app", "u1", "card", "dark")).toBe("https://codey.app/b/u1");
expect(badgeImageUrl("https://codey.app", "u1", "flat", "light")).toBe("https://codey.app/b/u1?style=flat&theme=light");
});

it("gives Markdown for a fixed theme and <picture> for auto", () => {
expect(badgeSnippet("https://codey.app", "u1", "card", "dark")).toBe("[![Codey typing stats](https://codey.app/b/u1)](https://codey.app/p/u1)");
const auto = badgeSnippet("https://codey.app", "u1", "flat", "auto");
expect(auto).toContain('<source media="(prefers-color-scheme: dark)" srcset="https://codey.app/b/u1?style=flat">');
expect(auto).toContain('src="https://codey.app/b/u1?style=flat&theme=light"');
});
});
Loading
Loading