Tern: documentation
A five-page documentation site: search-first landing, a guide with synced language tabs, filterable API reference, search with highlighted matches and a changelog, with ⌘K search in the header.
// Ballmac UI: Docs template fonts. https://ui.ballmac.com/templates/template-docs
import { Instrument_Sans, JetBrains_Mono, Newsreader } from "next/font/google"
export const docsSerif = Newsreader({ variable: "--docs-serif", subsets: ["latin"], display: "swap", preload: false })
export const docsSans = Instrument_Sans({ variable: "--docs-sans", subsets: ["latin"], display: "swap", preload: false })
export const docsMono = JetBrains_Mono({ variable: "--docs-mono", subsets: ["latin"], display: "swap", preload: false })// Ballmac UI: Docs template data. https://ui.ballmac.com/templates/template-docs
export type DocsKind = "Guide" | "API" | "Changelog"
export type DocsEntry = { title: string; kind: DocsKind; path: string; summary: string; page: "guide" | "reference" | "changelog" }
/** Everything the search page and the command menu can find. */
export const searchIndex: DocsEntry[] = [
{ title: "Send your first message", kind: "Guide", path: "Get started", summary: "Install the SDK, create a queue, send a message and acknowledge it in about seven minutes.", page: "guide" },
{ title: "Delivery guarantees", kind: "Guide", path: "Concepts", summary: "At-least-once delivery, visibility timeouts and how a message is retried before it reaches the dead-letter queue.", page: "guide" },
{ title: "Dead-letter queues", kind: "Guide", path: "Concepts", summary: "Park messages that keep failing, inspect them and replay them back into the queue once the bug is fixed.", page: "guide" },
{ title: "Idempotent consumers", kind: "Guide", path: "Guides", summary: "Use the message id as an idempotency key so a retried message never charges a card twice.", page: "guide" },
{ title: "Scheduling and delays", kind: "Guide", path: "Guides", summary: "Deliver a message in thirty seconds or next Tuesday with delay and a cron-style schedule.", page: "guide" },
{ title: "Verify webhook signatures", kind: "Guide", path: "Guides", summary: "Check the Tern-Signature header with an HMAC before you trust a push delivery.", page: "guide" },
{ title: "Create a queue", kind: "API", path: "POST /v1/queues", summary: "Creates a queue with a name, a visibility timeout and an optional dead-letter target.", page: "reference" },
{ title: "List queues", kind: "API", path: "GET /v1/queues", summary: "Returns every queue in the project, newest first, with a cursor for the next page.", page: "reference" },
{ title: "Send a message", kind: "API", path: "POST /v1/queues/{queue}/messages", summary: "Adds a message to a queue. Pass an idempotency key to make retries safe.", page: "reference" },
{ title: "Receive messages", kind: "API", path: "GET /v1/queues/{queue}/messages", summary: "Long-polls a queue and hides each message from other consumers until its visibility timeout ends.", page: "reference" },
{ title: "Acknowledge a message", kind: "API", path: "DELETE /v1/queues/{queue}/messages/{id}", summary: "Deletes a message once it has been processed successfully.", page: "reference" },
{ title: "Purge a queue", kind: "API", path: "POST /v1/queues/{queue}/purge", summary: "Removes every waiting message. This cannot be undone.", page: "reference" },
{ title: "3.2: Message replay", kind: "Changelog", path: "September 2026", summary: "Replay any window of the last 14 days into a queue without touching producers.", page: "changelog" },
{ title: "3.1: Batch receive", kind: "Changelog", path: "August 2026", summary: "Receive up to 50 messages per call and acknowledge them in one request.", page: "changelog" },
{ title: "3.0: Regions", kind: "Changelog", path: "June 2026", summary: "Queues can now live in Frankfurt, Virginia or Singapore. Breaking: the default region is explicit.", page: "changelog" },
]
export type NavSection = { title: string; items: { title: string; page: "guide" | "reference" | "changelog"; current?: boolean }[] }
export const nav: NavSection[] = [
{ title: "Get started", items: [{ title: "Send your first message", page: "guide", current: true }, { title: "Core concepts", page: "guide" }, { title: "Authentication", page: "reference" }] },
{ title: "Concepts", items: [{ title: "Delivery guarantees", page: "guide" }, { title: "Visibility timeouts", page: "guide" }, { title: "Dead-letter queues", page: "guide" }] },
{ title: "Guides", items: [{ title: "Idempotent consumers", page: "guide" }, { title: "Scheduling and delays", page: "guide" }, { title: "Verify webhook signatures", page: "guide" }, { title: "Going to production", page: "guide" }] },
{ title: "API reference", items: [{ title: "Queues", page: "reference", current: true }, { title: "Messages", page: "reference" }, { title: "Errors and limits", page: "reference" }] },
{ title: "Releases", items: [{ title: "Changelog", page: "changelog", current: true }] },
]
export type Release = { version: string; date: string; title: string; tag: "Added" | "Improved" | "Fixed" | "Breaking"; body: string; points: string[] }
export const releases: Release[] = [
{ version: "3.2.0", date: "2026-09-24", title: "Message replay", tag: "Added", body: "Replay a window of past messages into any queue. Useful after a bad deploy, or to seed a new environment with real traffic.", points: ["Replay up to 14 days of history by time range or by message id", "Replays are rate limited per queue so they never starve live traffic", "New `tern replay` command and `POST /v1/queues/{queue}/replays`"] },
{ version: "3.1.4", date: "2026-09-10", title: "Faster long polling", tag: "Improved", body: "Receive calls now wake within 40 ms of a message arriving instead of waiting for the next poll tick.", points: ["Median receive latency fell from 180 ms to 46 ms", "No change needed: the improvement applies to every SDK"] },
{ version: "3.1.3", date: "2026-08-29", title: "Visibility timeout on batch receive", tag: "Fixed", body: "Messages received in a batch of 50 could become visible again early if the first acknowledgement was slow.", points: ["The visibility timer now starts per message, not per batch", "Affected SDKs: node 3.1.0 to 3.1.2, python 3.1.0 to 3.1.1"] },
{ version: "3.1.0", date: "2026-08-12", title: "Batch receive and acknowledge", tag: "Added", body: "Receive up to 50 messages per call and acknowledge them in a single request. Throughput on busy queues roughly doubles.", points: ["`maxMessages` on receive, `ids` on acknowledge", "Partial failures return a per-message result", "SDK helpers `queue.consume()` batch for you"] },
{ version: "3.0.0", date: "2026-06-03", title: "Regions are explicit", tag: "Breaking", body: "Queues now live in a region you choose. The old global endpoint is retired, so clients must set a region.", points: ["Set `region` when creating the client: `eu`, `us` or `ap`", "Existing queues were migrated to `us` with no downtime", "The global endpoint returns 410 on 1 December 2026"] },
{ version: "2.9.2", date: "2026-05-14", title: "Clearer error messages", tag: "Improved", body: "Every error now includes a stable `code`, a human sentence and a link to the page that explains the fix.", points: ["`queue_not_found`, `visibility_expired` and 14 more codes documented", "Errors in the dashboard link straight to the docs"] },
]
export const languages = ["Node", "Python", "Go", "cURL"] as const
export const endpointGroups = [
{
title: "Queues",
endpoints: [
{
method: "POST" as const, path: "/v1/queues", summary: "Create a queue", description: "Creates a queue in the region of the client. Names are unique per project.",
parameters: [
{ name: "name", in: "body" as const, type: "string", required: true, description: "Lowercase letters, numbers and dashes, up to 64 characters." },
{ name: "visibilityTimeout", in: "body" as const, type: "integer", description: "Seconds a received message stays hidden.", default: "30" },
{ name: "deadLetter", in: "body" as const, type: "string", description: "Name of the queue that receives messages after maxReceives failures." },
{ name: "maxReceives", in: "body" as const, type: "integer", description: "Receives before a message is moved to the dead-letter queue.", default: "5" },
],
requestExample: `{\n "name": "invoices",\n "visibilityTimeout": 60,\n "deadLetter": "invoices-dead",\n "maxReceives": 5\n}`,
responses: [
{ status: 201, description: "Queue created", example: `{\n "name": "invoices",\n "region": "eu",\n "visibilityTimeout": 60,\n "createdAt": "2026-09-30T08:14:22Z"\n}` },
{ status: 409, description: "A queue with this name already exists", example: `{\n "code": "queue_exists",\n "message": "A queue named invoices already exists in eu."\n}` },
],
},
{
method: "GET" as const, path: "/v1/queues", summary: "List queues", description: "Returns the queues in the project, newest first.",
parameters: [
{ name: "limit", in: "query" as const, type: "integer", description: "Queues per page, up to 100.", default: "25" },
{ name: "cursor", in: "query" as const, type: "string", description: "The `next` value from the previous page." },
],
responses: [{ status: 200, description: "A page of queues", example: `{\n "data": [{ "name": "invoices", "waiting": 12, "inFlight": 3 }],\n "next": null\n}` }],
},
{
method: "POST" as const, path: "/v1/queues/{queue}/purge", summary: "Purge a queue", description: "Removes every waiting message. In-flight messages finish normally. This cannot be undone.",
parameters: [{ name: "queue", in: "path" as const, type: "string", required: true, description: "The queue name." }],
responses: [{ status: 202, description: "Purge accepted", example: `{ "removed": 1204 }` }, { status: 404, description: "No such queue", example: `{ "code": "queue_not_found" }` }],
},
],
},
{
title: "Messages",
endpoints: [
{
method: "POST" as const, path: "/v1/queues/{queue}/messages", summary: "Send a message", description: "Adds a message to the queue. Send an `Idempotency-Key` header and a retry will return the first result instead of adding a duplicate.",
parameters: [
{ name: "queue", in: "path" as const, type: "string", required: true, description: "The queue name." },
{ name: "Idempotency-Key", in: "header" as const, type: "string", description: "Any string up to 255 characters. Keys expire after 24 hours." },
{ name: "body", in: "body" as const, type: "object", required: true, description: "Any JSON up to 256 KB." },
{ name: "delay", in: "body" as const, type: "integer", description: "Seconds before the message becomes visible, up to 15 minutes.", default: "0" },
],
requestExample: `{\n "body": { "invoice": "inv_2041", "total": 4200 },\n "delay": 0\n}`,
responses: [{ status: 201, description: "Message accepted", example: `{\n "id": "msg_7Hq2Zc",\n "queue": "invoices",\n "visibleAt": "2026-09-30T08:14:22Z"\n}` }, { status: 413, description: "Body is larger than 256 KB", example: `{ "code": "body_too_large" }` }],
},
{
method: "GET" as const, path: "/v1/queues/{queue}/messages", summary: "Receive messages", description: "Long-polls for up to `wait` seconds. Each message is hidden from other consumers until its visibility timeout ends.",
parameters: [
{ name: "queue", in: "path" as const, type: "string", required: true, description: "The queue name." },
{ name: "maxMessages", in: "query" as const, type: "integer", description: "Up to 50 per call.", default: "1" },
{ name: "wait", in: "query" as const, type: "integer", description: "Seconds to wait for a message, up to 20.", default: "0" },
],
responses: [{ status: 200, description: "Zero or more messages", example: `{\n "data": [\n { "id": "msg_7Hq2Zc", "receipt": "rcpt_91", "body": { "invoice": "inv_2041" }, "receives": 1 }\n ]\n}` }],
},
{
method: "DELETE" as const, path: "/v1/queues/{queue}/messages/{id}", summary: "Acknowledge a message", description: "Deletes a message once it has been processed. Use the receipt from the receive call.",
parameters: [
{ name: "queue", in: "path" as const, type: "string", required: true, description: "The queue name." },
{ name: "id", in: "path" as const, type: "string", required: true, description: "The message id." },
{ name: "receipt", in: "query" as const, type: "string", required: true, description: "Proves you hold the message right now." },
],
responses: [{ status: 204, description: "Deleted" }, { status: 410, description: "The visibility timeout ended; the message may already be with another consumer", example: `{ "code": "visibility_expired" }` }],
},
],
},
]
export const snippets = {
install: { Node: "npm install @tern/sdk", Python: "pip install tern", Go: "go get github.com/tern/tern-go", cURL: "# nothing to install" },
}// Ballmac UI: Docs template shell. https://ui.ballmac.com/templates/template-docs
"use client"
import * as React from "react"
import { ChevronRight, Menu, Search } from "lucide-react"
import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ballmac/command"
import { Kbd } from "@/components/ballmac/kbd"
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ballmac/sheet"
import { nav, searchIndex, type DocsKind } from "@/components/ballmac/templates/docs/docs-data"
import { docsMono, docsSans, docsSerif } from "@/components/ballmac/templates/docs/docs-fonts"
import { cn } from "@/lib/utils"
type DocsPage = "home" | "guide" | "reference" | "search" | "changelog"
type DocsHrefs = Record<DocsPage, string>
const defaultHrefs: DocsHrefs = { home: "/docs", guide: "/docs/guides", reference: "/docs/reference", search: "/docs/search", changelog: "/docs/changelog" }
/** Tern's palette: a cool, quiet page with deep teal ink. Dark mode is the same page lit by a terminal. */
const docsCss = `
.docs-theme,body:not(:has([data-gallery])):has(.docs-theme){--background:oklch(0.985 0.006 210);--foreground:oklch(0.2 0.025 235);--card:oklch(1 0 0);--card-foreground:oklch(0.2 0.025 235);--popover:oklch(1 0 0);--popover-foreground:oklch(0.2 0.025 235);--primary:oklch(0.4 0.09 200);--primary-foreground:oklch(0.985 0.006 210);--secondary:oklch(0.955 0.012 210);--secondary-foreground:oklch(0.2 0.025 235);--muted:oklch(0.955 0.012 210);--muted-foreground:oklch(0.46 0.03 235);--accent:oklch(0.945 0.02 200);--accent-foreground:oklch(0.2 0.025 235);--border:oklch(0.2 0.025 235 / 12%);--input:oklch(0.2 0.025 235 / 18%);--ring:oklch(0.5 0.11 195);--surface:oklch(0.97 0.01 210);--destructive:oklch(0.52 0.21 27);--chart-1:oklch(0.5 0.11 190);--chart-2:oklch(0.5 0.15 265);--chart-3:oklch(0.62 0.15 75);--chart-4:oklch(0.55 0.15 150);--chart-5:oklch(0.55 0.2 20);--radius:0.75rem}
.dark .docs-theme,.dark body:not(:has([data-gallery])):has(.docs-theme){--background:oklch(0.165 0.02 235);--foreground:oklch(0.95 0.01 205);--card:oklch(0.2 0.022 235);--card-foreground:oklch(0.95 0.01 205);--popover:oklch(0.22 0.024 235);--popover-foreground:oklch(0.95 0.01 205);--primary:oklch(0.8 0.11 190);--primary-foreground:oklch(0.18 0.03 235);--secondary:oklch(0.25 0.025 235);--secondary-foreground:oklch(0.95 0.01 205);--muted:oklch(0.25 0.025 235);--muted-foreground:oklch(0.72 0.03 215);--accent:oklch(0.28 0.03 225);--accent-foreground:oklch(0.95 0.01 205);--border:oklch(1 0 0 / 10%);--input:oklch(1 0 0 / 15%);--ring:oklch(0.8 0.11 190);--surface:oklch(0.19 0.021 235);--destructive:oklch(0.7 0.19 27);--chart-1:oklch(0.8 0.11 190);--chart-2:oklch(0.76 0.12 265);--chart-3:oklch(0.82 0.13 80);--chart-4:oklch(0.78 0.13 150);--chart-5:oklch(0.74 0.14 20)}
body:not(:has([data-gallery])):has(.docs-theme){font-family:var(--docs-sans),ui-sans-serif,system-ui,sans-serif}
@keyframes docs-packet{0%{transform:translateX(0);opacity:0}10%{opacity:1}90%{opacity:1}100%{transform:translateX(var(--docs-travel,10rem));opacity:0}}
.docs-packet{animation:docs-packet 2.6s ease-in-out infinite}
@media (prefers-reduced-motion:reduce){.docs-packet{animation:none;opacity:1}}
`
const serif = "[font-family:var(--docs-serif),ui-serif,Georgia,serif] font-medium tracking-[-0.015em]"
const mono = "[font-family:var(--docs-mono),ui-monospace,monospace]"
const kindStyle: Record<DocsKind, string> = { Guide: "bg-chart-1/15", API: "bg-chart-2/15", Changelog: "bg-chart-3/20" }
function Logo({ className }: { className?: string }) {
return (
<span aria-hidden="true" className={cn("bg-primary text-primary-foreground inline-flex size-8 items-center justify-center rounded-lg", className)}>
<svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 8h9a4 4 0 0 1 0 8H8" /><path d="m11 13-3 3 3 3" /></svg>
</span>
)
}
type SidebarProps = { page: DocsPage; hrefs: DocsHrefs; onNavigate?: () => void }
function SidebarNav({ page, hrefs, onNavigate }: SidebarProps) {
return (
<nav aria-label="Documentation" className="grid gap-6 text-sm">
{nav.map((section) => (
<div key={section.title}>
<h2 className="text-foreground mb-2 px-2 text-xs font-bold tracking-[0.12em] uppercase">{section.title}</h2>
<ul className="grid gap-0.5">
{section.items.map((item) => {
const current = item.page === page && !!item.current
return (
<li key={item.title}>
<a
href={hrefs[item.page]}
onClick={onNavigate}
aria-current={current ? "page" : undefined}
className="text-muted-foreground hover:text-foreground aria-[current=page]:bg-accent aria-[current=page]:text-foreground focus-visible:ring-ring/50 relative block rounded-md px-2 py-1.5 outline-none transition-colors focus-visible:ring-[3px] aria-[current=page]:font-semibold motion-reduce:transition-none"
>
{item.title}
</a>
</li>
)
})}
</ul>
</div>
))}
</nav>
)
}
type DocsShellProps = React.ComponentProps<"div"> & {
/** The page being shown, so its nav link is marked current. */
page: DocsPage
/** Override where pages live (used by previews). */
hrefs?: Partial<DocsHrefs>
/** Show the left navigation. Landing and search pages turn it off. */
sidebar?: boolean
}
/** Tern's frame: a sticky header with the ⌘K search, a version picker, a sidebar and a footer. */
function DocsShell({ page, hrefs: overrides, sidebar = true, className, style, children, ...props }: DocsShellProps) {
const hrefs = { ...defaultHrefs, ...overrides }
const [searchOpen, setSearchOpen] = React.useState(false)
const [menuOpen, setMenuOpen] = React.useState(false)
React.useEffect(() => {
const classes = [docsSerif.variable, docsSans.variable, docsMono.variable].filter(Boolean)
document.body.classList.add(...classes)
return () => document.body.classList.remove(...classes)
}, [])
return (
<div
data-slot="docs"
className={cn("docs-theme bg-background text-foreground relative min-h-dvh overflow-x-clip", docsSerif.variable, docsSans.variable, docsMono.variable, className)}
style={{ fontFamily: "var(--docs-sans), ui-sans-serif, system-ui, sans-serif", ...style }}
{...props}
>
<style>{docsCss}</style>
<header className="bg-background/85 sticky top-0 z-40 border-b backdrop-blur-xl">
<div className="mx-auto flex h-16 max-w-[90rem] items-center gap-3 px-4 sm:px-6">
{sidebar && (
<Sheet open={menuOpen} onOpenChange={setMenuOpen}>
<SheetTrigger className="hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded-lg outline-none focus-visible:ring-[3px] lg:hidden" aria-label="Open navigation"><Menu className="size-5" aria-hidden="true" /></SheetTrigger>
<SheetContent side="start" className="docs-theme bg-background w-80 overflow-y-auto p-5">
<SheetHeader className="p-0 pb-4"><SheetTitle>Tern docs</SheetTitle><SheetDescription>Browse every guide and the API reference.</SheetDescription></SheetHeader>
<SidebarNav page={page} hrefs={hrefs} onNavigate={() => setMenuOpen(false)} />
</SheetContent>
</Sheet>
)}
<a href={hrefs.home} className="focus-visible:ring-ring/50 flex items-center gap-2.5 rounded-lg outline-none focus-visible:ring-[3px]">
<Logo />
<span className={cn("text-2xl", serif)}>Tern</span>
<span className="text-muted-foreground hidden border-s ps-2.5 text-sm font-semibold sm:inline">Docs</span>
</a>
<nav aria-label="Main" className="ms-4 hidden items-center gap-1 text-sm font-medium md:flex">
{([["guide", "Guides"], ["reference", "API reference"], ["changelog", "Changelog"]] as const).map(([key, label]) => (
<a key={key} href={hrefs[key]} aria-current={page === key ? "page" : undefined} className="text-muted-foreground hover:text-foreground aria-[current=page]:text-foreground focus-visible:ring-ring/50 rounded-md px-3 py-2 outline-none transition-colors focus-visible:ring-[3px] aria-[current=page]:font-semibold motion-reduce:transition-none">{label}</a>
))}
</nav>
<div className="ms-auto flex items-center gap-2">
<button type="button" onClick={() => setSearchOpen(true)} className="bg-surface hover:bg-accent focus-visible:ring-ring/50 text-muted-foreground inline-flex h-10 items-center gap-2 rounded-lg border px-3 text-sm outline-none transition-colors focus-visible:ring-[3px] sm:w-64 motion-reduce:transition-none" aria-label="Search the docs">
<Search className="size-4 shrink-0" aria-hidden="true" />
<span className="hidden flex-1 text-start sm:inline">Search the docs</span>
<span className="hidden items-center gap-1 sm:inline-flex" aria-hidden="true"><Kbd>⌘</Kbd><Kbd>K</Kbd></span>
</button>
<label className="sr-only" htmlFor="docs-version">API version</label>
<select id="docs-version" defaultValue="v3" className="bg-background focus-visible:ring-ring/50 h-10 rounded-lg border px-2.5 text-sm font-medium outline-none focus-visible:ring-[3px]">
<option value="v3">v3.2</option>
<option value="v2">v2.9</option>
</select>
</div>
</div>
</header>
<CommandDialog open={searchOpen} onOpenChange={setSearchOpen} title="Search the docs" description="Type to search guides, the API reference and the changelog.">
<Command>
<CommandInput placeholder="Search guides, endpoints and releases" />
<CommandList>
<CommandEmpty>No results. Try “queue” or “retry”.</CommandEmpty>
{(["Guide", "API", "Changelog"] as const).map((kind) => (
<CommandGroup key={kind} heading={kind === "API" ? "API reference" : kind === "Guide" ? "Guides" : "Releases"}>
{searchIndex.filter((e) => e.kind === kind).map((e) => (
<CommandItem key={e.title} value={`${e.title} ${e.path} ${e.summary}`} onSelect={() => { setSearchOpen(false); window.location.href = hrefs[e.page] }}>
<span className="grid min-w-0"><span className="truncate font-medium">{e.title}</span><span className={cn("text-muted-foreground truncate text-xs", kind === "API" && mono)}>{e.path}</span></span>
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</Command>
</CommandDialog>
{sidebar ? (
<div className="mx-auto grid max-w-[90rem] grid-cols-[minmax(0,1fr)] lg:grid-cols-[16rem_minmax(0,1fr)]">
<aside className="sticky top-16 hidden h-[calc(100dvh-4rem)] overflow-y-auto border-e p-6 pe-4 lg:block" aria-label="Sidebar">
<SidebarNav page={page} hrefs={hrefs} />
</aside>
<div className="min-w-0">{children}</div>
</div>
) : (
children
)}
<footer className="border-t">
<div className="mx-auto grid max-w-[90rem] gap-8 px-4 py-12 sm:px-6 md:grid-cols-[1.4fr_1fr_1fr_1fr]">
<div><a href={hrefs.home} className="flex items-center gap-2.5"><Logo className="size-7" /><span className={cn("text-xl", serif)}>Tern</span></a><p className="text-muted-foreground mt-3 max-w-xs text-sm text-pretty">Queues that never lose a message. Built by people who have been paged at 3 a.m.</p></div>
{[
["Learn", [["Quickstart", hrefs.guide], ["Concepts", hrefs.guide], ["Guides", hrefs.guide]]],
["Build", [["API reference", hrefs.reference], ["SDKs", hrefs.reference], ["Changelog", hrefs.changelog]]],
["Help", [["Search", hrefs.search], ["Status", hrefs.home], ["Contact support", hrefs.home]]],
].map(([title, items]) => (
<div key={title as string}><h2 className="text-xs font-bold tracking-[0.12em] uppercase">{title as string}</h2><ul className="mt-3 grid gap-2 text-sm">{(items as string[][]).map(([l, h]) => <li key={l}><a href={h} className="text-muted-foreground hover:text-foreground">{l}</a></li>)}</ul></div>
))}
</div>
<p className="text-muted-foreground mx-auto max-w-[90rem] border-t px-4 py-5 text-xs sm:px-6">© 2026 Tern Systems. All systems operational.</p>
</footer>
</div>
)
}
/** A small crumb row: "Get started › Send your first message". */
function Crumbs({ items }: { items: string[] }) {
return (
<nav aria-label="Breadcrumb" className="text-muted-foreground flex flex-wrap items-center gap-1 text-sm">
{items.map((it, i) => (
<React.Fragment key={it}>
{i > 0 && <ChevronRight className="size-3.5 rtl:rotate-180" aria-hidden="true" />}
<span aria-current={i === items.length - 1 ? "page" : undefined} className={i === items.length - 1 ? "text-foreground font-medium" : undefined}>{it}</span>
</React.Fragment>
))}
</nav>
)
}
export { Crumbs, DocsShell, Logo as DocsLogo, defaultHrefs as docsDefaultHrefs, kindStyle as docsKindStyle, mono as docsMonoClass, serif as docsSerifClass, type DocsHrefs, type DocsPage, type DocsShellProps }// Ballmac UI: Docs home page. https://ui.ballmac.com/templates/template-docs
"use client"
import * as React from "react"
import { ArrowRight, BookOpen, Code2, History, Rocket, Search } from "lucide-react"
import { SnippetTabs } from "@/components/ballmac/snippet-tabs"
import { releases, searchIndex, snippets } from "@/components/ballmac/templates/docs/docs-data"
import { DocsShell, docsMonoClass, docsSerifClass, type DocsHrefs } from "@/components/ballmac/templates/docs/docs-theme"
import { cn } from "@/lib/utils"
const paths = [
{ icon: Rocket, title: "Quickstart", body: "Send and receive your first message in about seven minutes.", key: "guide" as const, tint: "bg-chart-1/15" },
{ icon: BookOpen, title: "Guides", body: "Retries, dead letters, schedules and idempotent consumers, explained with code.", key: "guide" as const, tint: "bg-chart-2/15" },
{ icon: Code2, title: "API reference", body: "Every endpoint, parameter and error code, with examples in four languages.", key: "reference" as const, tint: "bg-chart-3/20" },
{ icon: History, title: "Changelog", body: "What shipped, what changed and what to do about it.", key: "changelog" as const, tint: "bg-chart-4/15" },
]
/** Producer, queue and consumer with packets travelling between them. Decorative; the sentence below carries the meaning. */
function Flow() {
const box = "bg-card grid h-20 w-24 shrink-0 place-items-center rounded-xl border text-center text-xs font-semibold shadow-xs sm:w-32 sm:text-sm"
return (
<figure className="mx-auto w-full max-w-xl" aria-label="How a message travels">
<div aria-hidden="true" className="flex items-center">
<div className={box}><span>Producer<span className={cn("text-muted-foreground block text-xs font-normal", docsMonoClass)}>your app</span></span></div>
<div className="relative h-px min-w-6 flex-1 border-t-2 border-dashed [--docs-travel:1.75rem] sm:[--docs-travel:5rem]"><span className="docs-packet bg-chart-1 absolute -top-[7px] start-0 size-3 rounded-full" /></div>
<div className={cn(box, "bg-primary text-primary-foreground border-primary")}><span>Queue<span className={cn("block text-xs font-normal opacity-100", docsMonoClass)}>invoices</span></span></div>
<div className="relative h-px min-w-6 flex-1 border-t-2 border-dashed [--docs-travel:1.75rem] sm:[--docs-travel:5rem]"><span className="docs-packet bg-chart-3 absolute -top-[7px] start-0 size-3 rounded-full" style={{ animationDelay: "1.1s" }} /></div>
<div className={box}><span>Consumer<span className={cn("text-muted-foreground block text-xs font-normal", docsMonoClass)}>worker</span></span></div>
</div>
<figcaption className="text-muted-foreground mt-4 text-center text-sm">A producer sends, Tern holds the message until a consumer acknowledges it.</figcaption>
</figure>
)
}
type DocsHomeProps = React.ComponentProps<"div"> & { hrefs?: Partial<DocsHrefs> }
/** The Docs landing page: a search-first hero, four paths in, install tabs and the latest releases. */
function DocsHome({ hrefs, ...props }: DocsHomeProps) {
const link = { ...{ home: "/docs", guide: "/docs/guides", reference: "/docs/reference", search: "/docs/search", changelog: "/docs/changelog" }, ...hrefs }
return (
<DocsShell page="home" hrefs={hrefs} sidebar={false} {...props}>
<main>
<section aria-labelledby="dh-title" className="relative isolate overflow-hidden border-b">
<div aria-hidden="true" className="absolute inset-0 -z-10 [background-image:radial-gradient(var(--border)_1.2px,transparent_1.2px)] [background-size:22px_22px] [mask-image:linear-gradient(to_bottom,black,transparent_85%)]" />
<div className="mx-auto max-w-5xl px-4 pt-16 pb-14 text-center sm:px-6 sm:pt-24">
<p className={cn("bg-accent text-accent-foreground inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold", docsMonoClass)}><span className="bg-chart-4 size-1.5 rounded-full" aria-hidden="true" />v3.2 · message replay is here</p>
<h1 id="dh-title" className={cn("mx-auto mt-6 max-w-3xl text-[clamp(2.8rem,7vw,5.5rem)] leading-[1.02] text-balance", docsSerifClass)}>Queues that <em className="text-primary">never lose</em> a message.</h1>
<p className="text-muted-foreground mx-auto mt-5 max-w-xl text-lg text-pretty">Everything you need to send, receive and retry messages at any scale, written by the people who run the queues.</p>
<form action={link.search} method="get" role="search" className="mx-auto mt-9 flex max-w-xl items-center gap-2">
<label htmlFor="dh-q" className="sr-only">Search the docs</label>
<div className="bg-card focus-within:ring-ring/50 relative flex-1 rounded-xl border shadow-sm focus-within:ring-[3px]">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 start-4 size-5 -translate-y-1/2" aria-hidden="true" />
<input id="dh-q" name="q" type="search" placeholder="Search: retries, idempotency, dead letters…" className="h-14 w-full rounded-xl bg-transparent pe-4 ps-12 text-base outline-none" />
</div>
<button type="submit" className="bg-primary text-primary-foreground focus-visible:ring-ring/50 h-14 rounded-xl px-6 text-base font-semibold outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px]">Search</button>
</form>
<p className="text-muted-foreground mt-4 text-sm">Popular: {["Retries", "Dead letters", "Idempotency"].map((t, i) => <React.Fragment key={t}>{i > 0 && ", "}<a href={`${link.search}?q=${t.toLowerCase()}`} className="text-foreground underline underline-offset-4">{t}</a></React.Fragment>)}</p>
<div className="mt-14"><Flow /></div>
</div>
</section>
<section aria-labelledby="dh-paths" className="mx-auto max-w-6xl px-4 py-16 sm:px-6">
<h2 id="dh-paths" className="sr-only">Where to start</h2>
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{paths.map((p) => (
<li key={p.title}>
<a href={link[p.key]} className="bg-card hover:border-primary/50 focus-visible:ring-ring/50 group flex h-full flex-col rounded-2xl border p-6 outline-none transition-[border-color,transform] hover:-translate-y-0.5 focus-visible:ring-[3px] motion-reduce:transition-none motion-reduce:hover:translate-y-0">
<span className={cn("inline-flex size-11 items-center justify-center rounded-xl", p.tint)}><p.icon className="size-5" aria-hidden="true" /></span>
<span className={cn("mt-5 text-2xl", docsSerifClass)}>{p.title}</span>
<span className="text-muted-foreground mt-2 flex-1 text-sm text-pretty">{p.body}</span>
<span className="text-primary mt-5 inline-flex items-center gap-1 text-sm font-semibold">Open <ArrowRight className="size-4 transition-transform group-hover:translate-x-1 motion-reduce:transition-none rtl:rotate-180 rtl:group-hover:-translate-x-1" aria-hidden="true" /></span>
</a>
</li>
))}
</ul>
</section>
<section aria-labelledby="dh-install" className="bg-surface border-y">
<div className="mx-auto grid max-w-6xl items-center gap-10 px-4 py-16 sm:px-6 lg:grid-cols-[1fr_1.1fr]">
<div>
<h2 id="dh-install" className={cn("text-4xl text-balance sm:text-5xl", docsSerifClass)}>One command, then a message.</h2>
<p className="text-muted-foreground mt-4 text-lg text-pretty">Official SDKs for Node, Python and Go, and a plain HTTP API for everything else. Pick a language once and every code sample in the docs follows you.</p>
<ul className="mt-6 grid gap-2 text-sm">{["Retries and backoff built in", "Typed clients, generated from the API", "Same behaviour in every language"].map((t) => <li key={t} className="flex items-center gap-2"><span className="bg-chart-4 size-1.5 rounded-full" aria-hidden="true" />{t}</li>)}</ul>
</div>
<SnippetTabs storageKey="tern-lang" title="Install" snippets={(Object.entries(snippets.install) as [string, string][]).map(([label, code]) => ({ label, code, language: "bash" }))} />
</div>
</section>
<section aria-labelledby="dh-pop" className="mx-auto grid max-w-6xl gap-12 px-4 py-16 sm:px-6 lg:grid-cols-2">
<div>
<h2 id="dh-pop" className={cn("text-3xl", docsSerifClass)}>Most read</h2>
<ol className="mt-5 divide-y border-y">
{searchIndex.filter((e) => e.kind === "Guide").slice(0, 5).map((e, i) => (
<li key={e.title}>
<a href={link[e.page]} className="hover:bg-accent focus-visible:ring-ring/50 group grid grid-cols-[2rem_1fr_auto] items-center gap-3 px-2 py-4 outline-none focus-visible:ring-[3px] focus-visible:ring-inset">
<span className={cn("text-muted-foreground text-sm", docsMonoClass)}>{String(i + 1).padStart(2, "0")}</span>
<span className="min-w-0"><span className="block font-semibold">{e.title}</span><span className="text-muted-foreground block truncate text-sm">{e.summary}</span></span>
<ArrowRight className="text-muted-foreground size-4 transition-transform group-hover:translate-x-1 motion-reduce:transition-none rtl:rotate-180 rtl:group-hover:-translate-x-1" aria-hidden="true" />
</a>
</li>
))}
</ol>
</div>
<div>
<div className="flex items-end justify-between"><h2 className={cn("text-3xl", docsSerifClass)}>What’s new</h2><a href={link.changelog} className="text-primary text-sm font-semibold underline-offset-4 hover:underline">All releases</a></div>
<ul className="mt-5 grid gap-4">
{releases.slice(0, 3).map((r) => (
<li key={r.version} className="bg-card rounded-2xl border p-5">
<div className="flex items-center gap-3"><span className={cn("bg-secondary rounded-md px-2 py-0.5 text-xs font-bold", docsMonoClass)}>{r.version}</span><span className="text-muted-foreground text-xs">{new Date(r.date + "T00:00:00Z").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })}</span></div>
<h3 className="mt-3 text-lg font-semibold">{r.title}</h3>
<p className="text-muted-foreground mt-1 text-sm text-pretty">{r.body}</p>
</li>
))}
</ul>
</div>
</section>
<section aria-labelledby="dh-help" className="mx-auto max-w-6xl px-4 pb-8 sm:px-6">
<div className="bg-primary text-primary-foreground flex flex-col items-start justify-between gap-6 rounded-3xl p-8 sm:flex-row sm:items-center sm:p-12">
<div><h2 id="dh-help" className={cn("text-3xl sm:text-4xl", docsSerifClass)}>Stuck? A human will answer.</h2><p className="mt-2 max-w-lg text-pretty">Average first reply on weekdays is eleven minutes. Bring your message id and we will find it.</p></div>
<a href={link.search} className="bg-primary-foreground text-primary focus-visible:ring-ring inline-flex h-12 shrink-0 items-center gap-2 rounded-xl px-6 font-semibold outline-none focus-visible:ring-[3px]">Ask support <ArrowRight className="size-4 rtl:rotate-180" aria-hidden="true" /></a>
</div>
</section>
</main>
</DocsShell>
)
}
export { DocsHome, type DocsHomeProps }// Ballmac UI: Docs guide page. https://ui.ballmac.com/templates/template-docs
"use client"
import * as React from "react"
import { ArrowLeft, ArrowRight, Check, Clock, ThumbsDown, ThumbsUp } from "lucide-react"
import { Callout } from "@/components/ballmac/callout"
import { CodeBlock } from "@/components/ballmac/code-block"
import { CopyButton } from "@/components/ballmac/copy-button"
import { InstallTabs } from "@/components/ballmac/install-tabs"
import { SnippetTabs } from "@/components/ballmac/snippet-tabs"
import { TableOfContents } from "@/components/ballmac/table-of-contents"
import { Crumbs, DocsShell, docsMonoClass, docsSerifClass, type DocsHrefs } from "@/components/ballmac/templates/docs/docs-theme"
import { cn } from "@/lib/utils"
const toc = [
{ id: "before", title: "Before you begin", level: 2 },
{ id: "install", title: "Install the SDK", level: 2 },
{ id: "create", title: "Create a queue", level: 2 },
{ id: "send", title: "Send a message", level: 2 },
{ id: "receive", title: "Receive and acknowledge", level: 2 },
{ id: "next", title: "Next steps", level: 2 },
]
const createSnippets = [
{ label: "Node", language: "typescript" as const, code: `const { Tern } = require("@tern/sdk")\n\nconst tern = new Tern({ apiKey: process.env.TERN_KEY, region: "eu" })\n\nawait tern.queues.create({\n name: "invoices",\n visibilityTimeout: 60,\n})` },
{ label: "Python", language: "python" as const, code: `import os\nfrom tern import Tern\n\ntern = Tern(api_key=os.environ["TERN_KEY"], region="eu")\n\ntern.queues.create(\n name="invoices",\n visibility_timeout=60,\n)` },
{ label: "Go", language: "go" as const, code: `client := tern.New(os.Getenv("TERN_KEY"), tern.WithRegion("eu"))\n\n_, err := client.Queues.Create(ctx, tern.CreateQueue{\n Name: "invoices",\n VisibilityTimeout: 60,\n})` },
{ label: "cURL", language: "bash" as const, code: `curl https://eu.api.tern.dev/v1/queues \\\n -H "Authorization: Bearer $TERN_KEY" \\\n -d '{"name":"invoices","visibilityTimeout":60}'` },
]
const sendSnippets = [
{ label: "Node", language: "typescript" as const, code: `const message = await tern.queue("invoices").send(\n { invoice: "inv_2041", total: 4200 },\n { idempotencyKey: "inv_2041" }\n)\n\nconsole.log(message.id) // msg_7Hq2Zc` },
{ label: "Python", language: "python" as const, code: `message = tern.queue("invoices").send(\n {"invoice": "inv_2041", "total": 4200},\n idempotency_key="inv_2041",\n)\n\nprint(message.id) # msg_7Hq2Zc` },
{ label: "Go", language: "go" as const, code: `msg, _ := client.Queue("invoices").Send(ctx,\n map[string]any{"invoice": "inv_2041", "total": 4200},\n tern.IdempotencyKey("inv_2041"))\n\nfmt.Println(msg.ID) // msg_7Hq2Zc` },
{ label: "cURL", language: "bash" as const, code: `curl https://eu.api.tern.dev/v1/queues/invoices/messages \\\n -H "Authorization: Bearer $TERN_KEY" \\\n -H "Idempotency-Key: inv_2041" \\\n -d '{"body":{"invoice":"inv_2041","total":4200}}'` },
]
const consumeSnippets = [
{ label: "Node", language: "typescript" as const, code: `for await (const msg of tern.queue("invoices").consume({ wait: 20 })) {\n await chargeCustomer(msg.body)\n await msg.ack() // delete it, or it comes back\n}` },
{ label: "Python", language: "python" as const, code: `for msg in tern.queue("invoices").consume(wait=20):\n charge_customer(msg.body)\n msg.ack() # delete it, or it comes back` },
{ label: "Go", language: "go" as const, code: `for msg := range client.Queue("invoices").Consume(ctx, tern.Wait(20)) {\n chargeCustomer(msg.Body)\n msg.Ack() // delete it, or it comes back\n}` },
{ label: "cURL", language: "bash" as const, code: `curl "https://eu.api.tern.dev/v1/queues/invoices/messages?wait=20" \\\n -H "Authorization: Bearer $TERN_KEY"` },
]
function Step({ n, title, id, children, last }: { n: number; title: string; id: string; children: React.ReactNode; last?: boolean }) {
return (
<section aria-labelledby={id} className="grid grid-cols-[2.5rem_minmax(0,1fr)] gap-x-4 sm:gap-x-5">
<div className="flex flex-col items-center">
<span aria-hidden="true" className={cn("bg-primary text-primary-foreground flex size-10 items-center justify-center rounded-full text-sm font-bold", docsMonoClass)}>{n}</span>
{!last && <span aria-hidden="true" className="bg-border mt-2 w-px flex-1" />}
</div>
<div className={cn("grid min-w-0 gap-4", !last && "pb-12")}>
<h2 id={id} className={cn("scroll-mt-24 pt-1 text-3xl", docsSerifClass)}>{title}</h2>
{children}
</div>
</section>
)
}
function Helpful() {
const [vote, setVote] = React.useState<"yes" | "no" | null>(null)
const [note, setNote] = React.useState("")
const [sent, setSent] = React.useState(false)
const btn = "hover:bg-accent focus-visible:ring-ring/50 aria-pressed:border-primary aria-pressed:bg-accent inline-flex h-10 items-center gap-2 rounded-lg border px-4 text-sm font-semibold outline-none focus-visible:ring-[3px]"
return (
<section aria-labelledby="dg-help" className="bg-surface rounded-2xl border p-6">
<h2 id="dg-help" className="font-semibold">Was this page helpful?</h2>
{sent || vote === "yes" ? (
<p role="status" className="mt-3 flex items-center gap-2 text-sm"><Check className="text-chart-4 size-4" aria-hidden="true" />{sent ? "Thanks. We read every note." : "Glad it helped. Thank you."}</p>
) : (
<>
<div className="mt-3 flex gap-2">
<button type="button" className={btn} onClick={() => setVote("yes")}><ThumbsUp className="size-4" aria-hidden="true" />Yes</button>
<button type="button" className={btn} aria-pressed={vote === "no"} onClick={() => setVote("no")}><ThumbsDown className="size-4" aria-hidden="true" />No</button>
</div>
{vote === "no" && (
<form className="mt-4 grid gap-3" onSubmit={(e) => { e.preventDefault(); setSent(true) }}>
<label htmlFor="dg-note" className="text-sm font-medium">What was missing or confusing?</label>
<textarea id="dg-note" value={note} onChange={(e) => setNote(e.target.value)} rows={3} className="bg-background focus-visible:ring-ring/50 w-full rounded-lg border p-3 text-sm outline-none focus-visible:ring-[3px]" />
<button type="submit" className="bg-primary text-primary-foreground focus-visible:ring-ring/50 h-10 w-fit rounded-lg px-5 text-sm font-semibold outline-none focus-visible:ring-[3px]">Send feedback</button>
</form>
)}
</>
)}
</section>
)
}
type DocsGuideProps = React.ComponentProps<"div"> & { hrefs?: Partial<DocsHrefs> }
/** A Docs guide: breadcrumbs, numbered steps with synced language tabs, callouts, an on-this-page list and a feedback box. */
function DocsGuide({ hrefs, ...props }: DocsGuideProps) {
const link = { guide: "/docs/guides", reference: "/docs/reference", ...hrefs }
return (
<DocsShell page="guide" hrefs={hrefs} {...props}>
<div className="grid grid-cols-[minmax(0,1fr)] gap-12 px-4 py-10 sm:px-8 xl:grid-cols-[minmax(0,1fr)_14rem]">
<main className="mx-auto w-full min-w-0 max-w-3xl">
<Crumbs items={["Get started", "Send your first message"]} />
<h1 className={cn("mt-5 text-[clamp(2.4rem,5vw,3.8rem)] leading-[1.05] text-balance", docsSerifClass)}>Send your first message</h1>
<p className="text-muted-foreground mt-4 text-xl text-pretty">Create a queue, put a message on it and take it off again. By the end you will have seen the whole loop that everything else is built on.</p>
<p className="text-muted-foreground mt-5 flex items-center gap-4 text-sm"><span className="inline-flex items-center gap-1.5"><Clock className="size-4" aria-hidden="true" />7 min read</span><span>Updated Sep 24, 2026</span></p>
<div className="mt-10">
<Step n={1} id="before" title="Before you begin">
<p className="text-pretty">You need a Tern account and a test API key. Test keys only touch test queues, so nothing here can affect production.</p>
<div className="bg-card flex items-center justify-between gap-3 rounded-xl border p-3 ps-4">
<div className="min-w-0"><p className="text-muted-foreground text-xs">Your test key</p><p className={cn("truncate text-sm", docsMonoClass)}>tern_test_4fJ2kQ9xN7mB1c</p></div>
<CopyButton value="tern_test_4fJ2kQ9xN7mB1c" ariaLabel="Copy the test key" variant="outline" size="sm" />
</div>
<Callout kind="note" title="Pick a region once">Queues live in one region. Use <code className={docsMonoClass}>eu</code>, <code className={docsMonoClass}>us</code> or <code className={docsMonoClass}>ap</code> and keep it the same in every client.</Callout>
</Step>
<Step n={2} id="install" title="Install the SDK">
<p className="text-pretty">Add the Node package with your package manager. Using Python or Go? Switch the tabs below and every sample on this page follows.</p>
<InstallTabs storageKey="tern-pm" commands={{ npm: "npm install @tern/sdk", pnpm: "pnpm add @tern/sdk", yarn: "yarn add @tern/sdk", bun: "bun add @tern/sdk" }} />
</Step>
<Step n={3} id="create" title="Create a queue">
<p className="text-pretty">A queue holds messages until a consumer acknowledges them. The visibility timeout is how long a received message stays hidden before Tern offers it to someone else.</p>
<SnippetTabs storageKey="tern-lang" title="Create a queue" lineNumbers snippets={createSnippets} />
<Callout kind="tip" title="Names are permanent">A queue cannot be renamed. Pick something you will still like in a year, such as <code className={docsMonoClass}>invoices</code> rather than <code className={docsMonoClass}>test2</code>.</Callout>
</Step>
<Step n={4} id="send" title="Send a message">
<p className="text-pretty">The body can be any JSON up to 256 KB. Pass an idempotency key and a network retry returns the first message instead of adding a second.</p>
<SnippetTabs storageKey="tern-lang" title="Send a message" lineNumbers snippets={sendSnippets} />
<CodeBlock filename="response.json" language="json" code={`{\n "id": "msg_7Hq2Zc",\n "queue": "invoices",\n "visibleAt": "2026-09-30T08:14:22Z"\n}`} highlight={[2]} />
</Step>
<Step n={5} id="receive" title="Receive and acknowledge" last>
<p className="text-pretty">Consuming is a loop: wait for a message, do the work, then acknowledge. Acknowledging deletes the message. If your code crashes first, the visibility timeout ends and Tern hands the message to the next consumer.</p>
<SnippetTabs storageKey="tern-lang" title="Consume messages" lineNumbers snippets={consumeSnippets} />
<Callout kind="caution" title="Acknowledge only after the work succeeds">Delivery is at least once. A message can arrive twice, so make your handler safe to run twice. The idempotent consumers guide shows how.</Callout>
</Step>
</div>
<section aria-labelledby="next" className="mt-14">
<h2 id="next" className={cn("scroll-mt-24 text-3xl", docsSerifClass)}>Next steps</h2>
<ul className="mt-5 grid gap-4 sm:grid-cols-2">
{[["Delivery guarantees", "What at-least-once really means for your code.", link.guide], ["Dead-letter queues", "Park messages that keep failing and replay them later.", link.guide], ["Idempotent consumers", "Make retries safe.", link.guide], ["API reference", "Every endpoint, with examples.", link.reference]].map(([t, d, h]) => (
<li key={t}><a href={h} className="bg-card hover:border-primary/50 focus-visible:ring-ring/50 group block h-full rounded-xl border p-5 outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none"><span className="flex items-center justify-between font-semibold">{t}<ArrowRight className="size-4 transition-transform group-hover:translate-x-1 motion-reduce:transition-none rtl:rotate-180 rtl:group-hover:-translate-x-1" aria-hidden="true" /></span><span className="text-muted-foreground mt-1 block text-sm">{d}</span></a></li>
))}
</ul>
</section>
<div className="mt-12"><Helpful /></div>
<nav aria-label="Previous and next page" className="mt-8 grid gap-4 sm:grid-cols-2">
<a href={link.guide} className="hover:border-primary/50 focus-visible:ring-ring/50 group rounded-xl border p-5 outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none"><span className="text-muted-foreground flex items-center gap-1 text-xs"><ArrowLeft className="size-3.5 rtl:rotate-180" aria-hidden="true" />Previous</span><span className="mt-1 block font-semibold">Core concepts</span></a>
<a href={link.guide} className="hover:border-primary/50 focus-visible:ring-ring/50 group rounded-xl border p-5 text-end outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none"><span className="text-muted-foreground flex items-center justify-end gap-1 text-xs">Next<ArrowRight className="size-3.5 rtl:rotate-180" aria-hidden="true" /></span><span className="mt-1 block font-semibold">Delivery guarantees</span></a>
</nav>
</main>
<aside className="hidden xl:block" aria-label="On this page"><div className="sticky top-24"><TableOfContents items={toc} title="On this page" offset={96} /></div></aside>
</div>
</DocsShell>
)
}
export { DocsGuide, type DocsGuideProps }// Ballmac UI: Docs API reference page. https://ui.ballmac.com/templates/template-docs
"use client"
import * as React from "react"
import { Search } from "lucide-react"
import { ApiEndpoint } from "@/components/ballmac/api-endpoint"
import { Callout } from "@/components/ballmac/callout"
import { CopyButton } from "@/components/ballmac/copy-button"
import { TableOfContents } from "@/components/ballmac/table-of-contents"
import { endpointGroups } from "@/components/ballmac/templates/docs/docs-data"
import { Crumbs, DocsShell, docsMonoClass, docsSerifClass, type DocsHrefs } from "@/components/ballmac/templates/docs/docs-theme"
import { cn } from "@/lib/utils"
const methods = ["All", "GET", "POST", "DELETE"] as const
const limits = [["Requests", "600 per minute per key"], ["Message size", "256 KB"], ["Batch receive", "50 messages"], ["Long poll", "20 seconds"], ["Idempotency keys", "24 hours"]]
const toc = [
{ id: "auth", title: "Authentication", level: 2 },
{ id: "limits", title: "Limits", level: 2 },
{ id: "queues", title: "Queues", level: 2 },
{ id: "messages", title: "Messages", level: 2 },
]
type DocsReferenceProps = React.ComponentProps<"div"> & { hrefs?: Partial<DocsHrefs> }
/** The API reference: authentication, limits and expandable endpoint cards that filter by method or text. */
function DocsReference({ hrefs, ...props }: DocsReferenceProps) {
const [method, setMethod] = React.useState<(typeof methods)[number]>("All")
const [query, setQuery] = React.useState("")
const q = query.trim().toLowerCase()
const groups = endpointGroups
.map((g) => ({ ...g, endpoints: g.endpoints.filter((e) => (method === "All" || e.method === method) && (!q || `${e.summary} ${e.path} ${e.description}`.toLowerCase().includes(q))) }))
.filter((g) => g.endpoints.length > 0)
const total = groups.reduce((n, g) => n + g.endpoints.length, 0)
return (
<DocsShell page="reference" hrefs={hrefs} {...props}>
<div className="grid grid-cols-[minmax(0,1fr)] gap-12 px-4 py-10 sm:px-8 xl:grid-cols-[minmax(0,1fr)_14rem]">
<main className="mx-auto w-full min-w-0 max-w-3xl">
<Crumbs items={["API reference", "Overview"]} />
<h1 className={cn("mt-5 text-[clamp(2.4rem,5vw,3.8rem)] leading-[1.05] text-balance", docsSerifClass)}>API reference</h1>
<p className="text-muted-foreground mt-4 text-xl text-pretty">A small, regular HTTP API. Every endpoint takes and returns JSON, and every SDK method maps to exactly one of them.</p>
<div className="bg-card mt-6 flex items-center justify-between gap-3 rounded-xl border p-3 ps-4">
<div className="min-w-0"><p className="text-muted-foreground text-xs">Base URL</p><p className={cn("truncate text-sm", docsMonoClass)}>https://eu.api.tern.dev</p></div>
<CopyButton value="https://eu.api.tern.dev" ariaLabel="Copy the base URL" variant="outline" size="sm" />
</div>
<section aria-labelledby="auth" className="mt-12">
<h2 id="auth" className={cn("scroll-mt-24 text-3xl", docsSerifClass)}>Authentication</h2>
<p className="mt-3 text-pretty">Send your key as a bearer token. Test keys start with <code className={docsMonoClass}>tern_test_</code> and live keys with <code className={docsMonoClass}>tern_live_</code>.</p>
<pre tabIndex={0} role="region" aria-label="Authorization header" className={cn("bg-muted/60 mt-4 overflow-x-auto rounded-xl border p-4 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", docsMonoClass)}>Authorization: Bearer tern_live_••••••••</pre>
<div className="mt-4"><Callout kind="caution" title="Keep live keys on the server">Never ship a live key in a browser or a mobile app. Create a restricted key that can only send messages if a client must call Tern directly.</Callout></div>
</section>
<section aria-labelledby="limits" className="mt-12">
<h2 id="limits" className={cn("scroll-mt-24 text-3xl", docsSerifClass)}>Limits</h2>
<dl className="mt-4 divide-y rounded-xl border">
{limits.map(([k, v]) => <div key={k} className="grid grid-cols-2 gap-4 px-4 py-3 text-sm"><dt className="text-muted-foreground">{k}</dt><dd className={cn("font-medium", docsMonoClass)}>{v}</dd></div>)}
</dl>
</section>
<div className="mt-14 flex flex-wrap items-center gap-3" role="group" aria-label="Filter endpoints">
<div className="relative min-w-52 flex-1">
<label htmlFor="dr-q" className="sr-only">Filter endpoints</label>
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 start-3 size-4 -translate-y-1/2" aria-hidden="true" />
<input id="dr-q" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Filter endpoints" className="bg-card focus-visible:ring-ring/50 h-10 w-full rounded-lg border pe-3 ps-9 text-sm outline-none focus-visible:ring-[3px]" />
</div>
<div className="flex gap-1.5">
{methods.map((m) => (
<button key={m} type="button" aria-pressed={method === m} onClick={() => setMethod(m)} className={cn("hover:bg-accent focus-visible:ring-ring/50 aria-pressed:bg-primary aria-pressed:text-primary-foreground h-10 rounded-lg border px-3 text-xs font-bold outline-none focus-visible:ring-[3px]", docsMonoClass)}>{m}</button>
))}
</div>
</div>
<p role="status" className="text-muted-foreground mt-3 text-sm">{total} {total === 1 ? "endpoint" : "endpoints"}</p>
{groups.map((g) => (
<section key={g.title} aria-labelledby={g.title.toLowerCase()} className="mt-8">
<h2 id={g.title.toLowerCase()} className={cn("scroll-mt-24 text-3xl", docsSerifClass)}>{g.title}</h2>
<div className="mt-5 grid gap-4">
{g.endpoints.map((e, i) => (
<ApiEndpoint key={e.method + e.path} method={e.method} path={e.path} summary={e.summary} description={e.description} baseUrl="https://eu.api.tern.dev" auth="Bearer key" parameters={e.parameters} requestExample={e.requestExample} responses={e.responses} defaultOpen={i === 0 && g.title === groups[0]?.title} />
))}
</div>
</section>
))}
{total === 0 && <div className="bg-surface mt-8 rounded-2xl border p-10 text-center"><p className={cn("text-2xl", docsSerifClass)}>No endpoint matches “{query}”.</p><p className="text-muted-foreground mt-2 text-sm">Try a shorter word, or clear the method filter.</p><button type="button" onClick={() => { setQuery(""); setMethod("All") }} className="bg-primary text-primary-foreground focus-visible:ring-ring/50 mt-5 h-10 rounded-lg px-5 text-sm font-semibold outline-none focus-visible:ring-[3px]">Clear filters</button></div>}
</main>
<aside className="hidden xl:block" aria-label="On this page"><div className="sticky top-24"><TableOfContents items={toc} title="On this page" offset={96} /></div></aside>
</div>
</DocsShell>
)
}
export { DocsReference, type DocsReferenceProps }// Ballmac UI: Docs search page. https://ui.ballmac.com/templates/template-docs
"use client"
import * as React from "react"
import { Search } from "lucide-react"
import { searchIndex, type DocsKind } from "@/components/ballmac/templates/docs/docs-data"
import { DocsShell, docsKindStyle, docsMonoClass, docsSerifClass, type DocsHrefs } from "@/components/ballmac/templates/docs/docs-theme"
import { cn } from "@/lib/utils"
const kinds = ["All", "Guide", "API", "Changelog"] as const
const suggestions = ["retry", "idempotency", "dead letter", "visibility", "region"]
function Marked({ text, query }: { text: string; query: string }) {
const terms = query.trim().split(/\s+/).filter(Boolean).map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
if (terms.length === 0) return <>{text}</>
const parts = text.split(new RegExp(`(${terms.join("|")})`, "ig"))
return <>{parts.map((p, i) => (i % 2 === 1 ? <mark key={i} className="bg-chart-3/35 text-foreground rounded-sm px-0.5">{p}</mark> : <React.Fragment key={i}>{p}</React.Fragment>))}</>
}
type DocsSearchProps = React.ComponentProps<"div"> & { hrefs?: Partial<DocsHrefs>; initialQuery?: string }
/** A full search page: filter by type, matches highlighted in titles and summaries, helpful suggestions when nothing matches. */
function DocsSearch({ hrefs, initialQuery = "message", ...props }: DocsSearchProps) {
const [query, setQuery] = React.useState(initialQuery)
const [kind, setKind] = React.useState<(typeof kinds)[number]>("All")
const link = { guide: "/docs/guides", reference: "/docs/reference", changelog: "/docs/changelog", ...hrefs }
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean)
const all = searchIndex.filter((e) => terms.every((t) => `${e.title} ${e.summary} ${e.path}`.toLowerCase().includes(t)))
const results = all.filter((e) => kind === "All" || e.kind === kind)
const count = (k: DocsKind) => all.filter((e) => e.kind === k).length
return (
<DocsShell page="search" hrefs={hrefs} sidebar={false} {...props}>
<main className="mx-auto max-w-3xl px-4 py-14 sm:px-6 sm:py-20">
<h1 className={cn("text-[clamp(2.4rem,6vw,4rem)] leading-none", docsSerifClass)}>Search</h1>
<form role="search" onSubmit={(e) => e.preventDefault()} className="mt-8">
<label htmlFor="ds-q" className="sr-only">Search the docs</label>
<div className="bg-card focus-within:ring-ring/50 relative rounded-xl border shadow-sm focus-within:ring-[3px]">
<Search className="text-muted-foreground pointer-events-none absolute top-1/2 start-4 size-5 -translate-y-1/2" aria-hidden="true" />
<input id="ds-q" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search guides, endpoints and releases" className="h-14 w-full rounded-xl bg-transparent pe-4 ps-12 text-base outline-none" />
</div>
</form>
<div className="mt-5 flex flex-wrap gap-2" role="group" aria-label="Filter by type">
{kinds.map((k) => (
<button key={k} type="button" aria-pressed={kind === k} onClick={() => setKind(k)} className="hover:bg-accent focus-visible:ring-ring/50 aria-pressed:bg-primary aria-pressed:text-primary-foreground inline-flex h-9 items-center gap-2 rounded-full border px-4 text-sm font-semibold outline-none focus-visible:ring-[3px]">
{k === "API" ? "API reference" : k === "Guide" ? "Guides" : k === "Changelog" ? "Releases" : "Everything"}
{k !== "All" && <span className={cn("text-xs opacity-100", docsMonoClass)}>{count(k)}</span>}
</button>
))}
</div>
<p role="status" className="text-muted-foreground mt-6 text-sm">{results.length} {results.length === 1 ? "result" : "results"}{query.trim() ? <> for “{query.trim()}”</> : null}</p>
{results.length > 0 ? (
<ul className="mt-2 divide-y">
{results.map((e) => (
<li key={e.title}>
<a href={link[e.page]} className="hover:bg-accent focus-visible:ring-ring/50 -mx-3 block rounded-xl px-3 py-5 outline-none focus-visible:ring-[3px]">
<span className="flex items-center gap-2 text-xs">
<span className={cn("rounded-md px-2 py-0.5 font-bold", docsKindStyle[e.kind])}>{e.kind === "API" ? "API" : e.kind}</span>
<span className={cn("text-muted-foreground truncate", e.kind === "API" && docsMonoClass)}>{e.path}</span>
</span>
<span className={cn("mt-2 block text-2xl", docsSerifClass)}><Marked text={e.title} query={query} /></span>
<span className="text-muted-foreground mt-1 block text-pretty"><Marked text={e.summary} query={query} /></span>
</a>
</li>
))}
</ul>
) : (
<div className="bg-surface mt-4 rounded-2xl border p-10 text-center">
<p className={cn("text-3xl", docsSerifClass)}>Nothing for “{query.trim()}”.</p>
<p className="text-muted-foreground mt-2">Check the spelling, try fewer words or start from one of these.</p>
<div className="mt-6 flex flex-wrap justify-center gap-2">{suggestions.map((s) => <button key={s} type="button" onClick={() => { setQuery(s); setKind("All") }} className="bg-card hover:bg-accent focus-visible:ring-ring/50 h-9 rounded-full border px-4 text-sm font-semibold outline-none focus-visible:ring-[3px]">{s}</button>)}</div>
</div>
)}
</main>
</DocsShell>
)
}
export { DocsSearch, type DocsSearchProps }// Ballmac UI: Docs changelog page. https://ui.ballmac.com/templates/template-docs
"use client"
import * as React from "react"
import { Rss } from "lucide-react"
import { releases, type Release } from "@/components/ballmac/templates/docs/docs-data"
import { DocsShell, docsMonoClass, docsSerifClass, type DocsHrefs } from "@/components/ballmac/templates/docs/docs-theme"
import { cn } from "@/lib/utils"
const tags = ["All", "Added", "Improved", "Fixed", "Breaking"] as const
const tagStyle: Record<Release["tag"], string> = { Added: "bg-chart-4/15", Improved: "bg-chart-2/15", Fixed: "bg-chart-3/20", Breaking: "bg-destructive/15" }
/** Renders `code` spans written with backticks. */
function Ticks({ text }: { text: string }) {
return <>{text.split("`").map((part, i) => (i % 2 === 1 ? <code key={i} className={cn("bg-muted rounded px-1 py-0.5 text-[0.85em]", docsMonoClass)}>{part}</code> : <React.Fragment key={i}>{part}</React.Fragment>))}</>
}
const fmt = (d: string) => new Date(d + "T00:00:00Z").toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" })
type DocsChangelogProps = React.ComponentProps<"div"> & { hrefs?: Partial<DocsHrefs> }
/** The changelog: a version rail, tag filters and each release with its upgrade notes. */
function DocsChangelog({ hrefs, ...props }: DocsChangelogProps) {
const [tag, setTag] = React.useState<(typeof tags)[number]>("All")
const list = releases.filter((r) => tag === "All" || r.tag === tag)
return (
<DocsShell page="changelog" hrefs={hrefs} {...props}>
<div className="grid grid-cols-[minmax(0,1fr)] gap-12 px-4 py-10 sm:px-8 xl:grid-cols-[minmax(0,1fr)_14rem]">
<main className="mx-auto w-full min-w-0 max-w-3xl">
<h1 className={cn("text-[clamp(2.4rem,5vw,3.8rem)] leading-[1.05]", docsSerifClass)}>Changelog</h1>
<p className="text-muted-foreground mt-4 text-xl text-pretty">What shipped, what changed and what to do about it. Breaking changes always get a major version and a month of notice.</p>
<div className="mt-6 flex flex-wrap items-center gap-2" role="group" aria-label="Filter releases">
{tags.map((t) => <button key={t} type="button" aria-pressed={tag === t} onClick={() => setTag(t)} className="hover:bg-accent focus-visible:ring-ring/50 aria-pressed:bg-primary aria-pressed:text-primary-foreground h-9 rounded-full border px-4 text-sm font-semibold outline-none focus-visible:ring-[3px]">{t}</button>)}
<a href="/docs/changelog.xml" className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 ms-auto inline-flex items-center gap-1.5 rounded-md text-sm font-medium outline-none focus-visible:ring-[3px]"><Rss className="size-4" aria-hidden="true" />RSS</a>
</div>
<p role="status" className="text-muted-foreground mt-4 text-sm">{list.length} {list.length === 1 ? "release" : "releases"}</p>
<ol className="mt-4">
{list.map((r, i) => (
<li key={r.version} id={`v${r.version}`} className="relative grid scroll-mt-24 grid-cols-[1.5rem_minmax(0,1fr)] gap-x-4">
<div className="flex flex-col items-center"><span aria-hidden="true" className={cn("mt-2 size-3 rounded-full border-2", r.tag === "Breaking" ? "border-destructive bg-destructive" : "border-primary bg-background")} />{i < list.length - 1 && <span aria-hidden="true" className="bg-border mt-1 w-px flex-1" />}</div>
<article aria-labelledby={`r-${r.version}`} className="pb-12">
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className={cn("bg-secondary rounded-md px-2 py-0.5 text-xs font-bold", docsMonoClass)}>{r.version}</span>
<span className={cn("rounded-md px-2 py-0.5 text-xs font-bold", tagStyle[r.tag])}>{r.tag}</span>
<time dateTime={r.date} className="text-muted-foreground">{fmt(r.date)}</time>
</div>
<h2 id={`r-${r.version}`} className={cn("mt-3 text-3xl", docsSerifClass)}>{r.title}</h2>
<p className="mt-2 text-pretty"><Ticks text={r.body} /></p>
<ul className="mt-4 grid gap-2">{r.points.map((p) => <li key={p} className="text-muted-foreground flex gap-3 text-sm"><span className="bg-border mt-2 size-1.5 shrink-0 rounded-full" aria-hidden="true" /><span className="text-pretty"><Ticks text={p} /></span></li>)}</ul>
</article>
</li>
))}
</ol>
{list.length === 0 && <p className="text-muted-foreground py-10 text-center">No releases with this tag yet.</p>}
</main>
<aside className="hidden xl:block" aria-label="Versions"><div className="sticky top-24"><h2 className="text-xs font-bold tracking-[0.12em] uppercase">Versions</h2><ul className="mt-3 grid gap-1 border-s text-sm">{releases.map((r) => <li key={r.version}><a href={`#v${r.version}`} className={cn("text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -ms-px block rounded-e-md border-s-2 border-transparent px-3 py-1 outline-none hover:border-current focus-visible:ring-[3px]", docsMonoClass)}>{r.version}</a></li>)}</ul></div></aside>
</div>
</DocsShell>
)
}
export { DocsChangelog, type DocsChangelogProps }// Ballmac UI: Docs template route. https://ui.ballmac.com/templates/template-docs
import type { Metadata } from "next"
import { DocsHome } from "@/components/ballmac/templates/docs/docs-home"
export const metadata: Metadata = {
title: "Tern docs: queues that never lose a message",
description: "Guides, API reference and changelog for Tern.",
}
export default function Page() {
return <DocsHome />
}// Ballmac UI: Docs template route. https://ui.ballmac.com/templates/template-docs
import type { Metadata } from "next"
import { DocsGuide } from "@/components/ballmac/templates/docs/docs-guide"
export const metadata: Metadata = {
title: "Send your first message · Tern docs",
description: "Create a queue, send a message and acknowledge it in about seven minutes.",
}
export default function Page() {
return <DocsGuide />
}// Ballmac UI: Docs template route. https://ui.ballmac.com/templates/template-docs
import type { Metadata } from "next"
import { DocsReference } from "@/components/ballmac/templates/docs/docs-reference"
export const metadata: Metadata = {
title: "API reference · Tern docs",
description: "Every endpoint, parameter and error code.",
}
export default function Page() {
return <DocsReference />
}// Ballmac UI: Docs template route. https://ui.ballmac.com/templates/template-docs
import type { Metadata } from "next"
import { DocsSearch } from "@/components/ballmac/templates/docs/docs-search"
export const metadata: Metadata = {
title: "Search · Tern docs",
description: "Search guides, endpoints and releases.",
}
export default function Page() {
return <DocsSearch />
}// Ballmac UI: Docs template route. https://ui.ballmac.com/templates/template-docs
import type { Metadata } from "next"
import { DocsChangelog } from "@/components/ballmac/templates/docs/docs-changelog"
export const metadata: Metadata = {
title: "Changelog · Tern docs",
description: "What shipped, what changed and what to do about it.",
}
export default function Page() {
return <DocsChangelog />
}Installation
$ pnpm dlx shadcn@latest add @ballmac/template-docsInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/command @ballmac/kbd @ballmac/sheet @ballmac/snippet-tabs @ballmac/install-tabs @ballmac/code-block @ballmac/callout @ballmac/copy-button @ballmac/table-of-contents @ballmac/api-endpointCopy the source into your project.
components/ballmac/templates/docs/docs-fonts.ts// Ballmac UI: Docs template fonts. https://ui.ballmac.com/templates/template-docs import { Instrument_Sans, JetBrains_Mono, Newsreader } from "next/font/google" export const docsSerif = Newsreader({ variable: "--docs-serif", subsets: ["latin"], display: "swap", preload: false }) export const docsSans = Instrument_Sans({ variable: "--docs-sans", subsets: ["latin"], display: "swap", preload: false }) export const docsMono = JetBrains_Mono({ variable: "--docs-mono", subsets: ["latin"], display: "swap", preload: false })components/ballmac/templates/docs/docs-data.ts// Ballmac UI: Docs template data. https://ui.ballmac.com/templates/template-docs export type DocsKind = "Guide" | "API" | "Changelog" export type DocsEntry = { title: string; kind: DocsKind; path: string; summary: string; page: "guide" | "reference" | "changelog" } /** Everything the search page and the command menu can find. */ export const searchIndex: DocsEntry[] = [ { title: "Send your first message", kind: "Guide", path: "Get started", summary: "Install the SDK, create a queue, send a message and acknowledge it in about seven minutes.", page: "guide" }, { title: "Delivery guarantees", kind: "Guide", path: "Concepts", summary: "At-least-once delivery, visibility timeouts and how a message is retried before it reaches the dead-letter queue.", page: "guide" }, { title: "Dead-letter queues", kind: "Guide", path: "Concepts", summary: "Park messages that keep failing, inspect them and replay them back into the queue once the bug is fixed.", page: "guide" }, { title: "Idempotent consumers", kind: "Guide", path: "Guides", summary: "Use the message id as an idempotency key so a retried message never charges a card twice.", page: "guide" }, { title: "Scheduling and delays", kind: "Guide", path: "Guides", summary: "Deliver a message in thirty seconds or next Tuesday with delay and a cron-style schedule.", page: "guide" }, { title: "Verify webhook signatures", kind: "Guide", path: "Guides", summary: "Check the Tern-Signature header with an HMAC before you trust a push delivery.", page: "guide" }, { title: "Create a queue", kind: "API", path: "POST /v1/queues", summary: "Creates a queue with a name, a visibility timeout and an optional dead-letter target.", page: "reference" }, { title: "List queues", kind: "API", path: "GET /v1/queues", summary: "Returns every queue in the project, newest first, with a cursor for the next page.", page: "reference" }, { title: "Send a message", kind: "API", path: "POST /v1/queues/{queue}/messages", summary: "Adds a message to a queue. Pass an idempotency key to make retries safe.", page: "reference" }, { title: "Receive messages", kind: "API", path: "GET /v1/queues/{queue}/messages", summary: "Long-polls a queue and hides each message from other consumers until its visibility timeout ends.", page: "reference" }, { title: "Acknowledge a message", kind: "API", path: "DELETE /v1/queues/{queue}/messages/{id}", summary: "Deletes a message once it has been processed successfully.", page: "reference" }, { title: "Purge a queue", kind: "API", path: "POST /v1/queues/{queue}/purge", summary: "Removes every waiting message. This cannot be undone.", page: "reference" }, { title: "3.2: Message replay", kind: "Changelog", path: "September 2026", summary: "Replay any window of the last 14 days into a queue without touching producers.", page: "changelog" }, { title: "3.1: Batch receive", kind: "Changelog", path: "August 2026", summary: "Receive up to 50 messages per call and acknowledge them in one request.", page: "changelog" }, { title: "3.0: Regions", kind: "Changelog", path: "June 2026", summary: "Queues can now live in Frankfurt, Virginia or Singapore. Breaking: the default region is explicit.", page: "changelog" }, ] export type NavSection = { title: string; items: { title: string; page: "guide" | "reference" | "changelog"; current?: boolean }[] } export const nav: NavSection[] = [ { title: "Get started", items: [{ title: "Send your first message", page: "guide", current: true }, { title: "Core concepts", page: "guide" }, { title: "Authentication", page: "reference" }] }, { title: "Concepts", items: [{ title: "Delivery guarantees", page: "guide" }, { title: "Visibility timeouts", page: "guide" }, { title: "Dead-letter queues", page: "guide" }] }, { title: "Guides", items: [{ title: "Idempotent consumers", page: "guide" }, { title: "Scheduling and delays", page: "guide" }, { title: "Verify webhook signatures", page: "guide" }, { title: "Going to production", page: "guide" }] }, { title: "API reference", items: [{ title: "Queues", page: "reference", current: true }, { title: "Messages", page: "reference" }, { title: "Errors and limits", page: "reference" }] }, { title: "Releases", items: [{ title: "Changelog", page: "changelog", current: true }] }, ] export type Release = { version: string; date: string; title: string; tag: "Added" | "Improved" | "Fixed" | "Breaking"; body: string; points: string[] } export const releases: Release[] = [ { version: "3.2.0", date: "2026-09-24", title: "Message replay", tag: "Added", body: "Replay a window of past messages into any queue. Useful after a bad deploy, or to seed a new environment with real traffic.", points: ["Replay up to 14 days of history by time range or by message id", "Replays are rate limited per queue so they never starve live traffic", "New `tern replay` command and `POST /v1/queues/{queue}/replays`"] }, { version: "3.1.4", date: "2026-09-10", title: "Faster long polling", tag: "Improved", body: "Receive calls now wake within 40 ms of a message arriving instead of waiting for the next poll tick.", points: ["Median receive latency fell from 180 ms to 46 ms", "No change needed: the improvement applies to every SDK"] }, { version: "3.1.3", date: "2026-08-29", title: "Visibility timeout on batch receive", tag: "Fixed", body: "Messages received in a batch of 50 could become visible again early if the first acknowledgement was slow.", points: ["The visibility timer now starts per message, not per batch", "Affected SDKs: node 3.1.0 to 3.1.2, python 3.1.0 to 3.1.1"] }, { version: "3.1.0", date: "2026-08-12", title: "Batch receive and acknowledge", tag: "Added", body: "Receive up to 50 messages per call and acknowledge them in a single request. Throughput on busy queues roughly doubles.", points: ["`maxMessages` on receive, `ids` on acknowledge", "Partial failures return a per-message result", "SDK helpers `queue.consume()` batch for you"] }, { version: "3.0.0", date: "2026-06-03", title: "Regions are explicit", tag: "Breaking", body: "Queues now live in a region you choose. The old global endpoint is retired, so clients must set a region.", points: ["Set `region` when creating the client: `eu`, `us` or `ap`", "Existing queues were migrated to `us` with no downtime", "The global endpoint returns 410 on 1 December 2026"] }, { version: "2.9.2", date: "2026-05-14", title: "Clearer error messages", tag: "Improved", body: "Every error now includes a stable `code`, a human sentence and a link to the page that explains the fix.", points: ["`queue_not_found`, `visibility_expired` and 14 more codes documented", "Errors in the dashboard link straight to the docs"] }, ] export const languages = ["Node", "Python", "Go", "cURL"] as const export const endpointGroups = [ { title: "Queues", endpoints: [ { method: "POST" as const, path: "/v1/queues", summary: "Create a queue", description: "Creates a queue in the region of the client. Names are unique per project.", parameters: [ { name: "name", in: "body" as const, type: "string", required: true, description: "Lowercase letters, numbers and dashes, up to 64 characters." }, { name: "visibilityTimeout", in: "body" as const, type: "integer", description: "Seconds a received message stays hidden.", default: "30" }, { name: "deadLetter", in: "body" as const, type: "string", description: "Name of the queue that receives messages after maxReceives failures." }, { name: "maxReceives", in: "body" as const, type: "integer", description: "Receives before a message is moved to the dead-letter queue.", default: "5" }, ], requestExample: `{\n "name": "invoices",\n "visibilityTimeout": 60,\n "deadLetter": "invoices-dead",\n "maxReceives": 5\n}`, responses: [ { status: 201, description: "Queue created", example: `{\n "name": "invoices",\n "region": "eu",\n "visibilityTimeout": 60,\n "createdAt": "2026-09-30T08:14:22Z"\n}` }, { status: 409, description: "A queue with this name already exists", example: `{\n "code": "queue_exists",\n "message": "A queue named invoices already exists in eu."\n}` }, ], }, { method: "GET" as const, path: "/v1/queues", summary: "List queues", description: "Returns the queues in the project, newest first.", parameters: [ { name: "limit", in: "query" as const, type: "integer", description: "Queues per page, up to 100.", default: "25" }, { name: "cursor", in: "query" as const, type: "string", description: "The `next` value from the previous page." }, ], responses: [{ status: 200, description: "A page of queues", example: `{\n "data": [{ "name": "invoices", "waiting": 12, "inFlight": 3 }],\n "next": null\n}` }], }, { method: "POST" as const, path: "/v1/queues/{queue}/purge", summary: "Purge a queue", description: "Removes every waiting message. In-flight messages finish normally. This cannot be undone.", parameters: [{ name: "queue", in: "path" as const, type: "string", required: true, description: "The queue name." }], responses: [{ status: 202, description: "Purge accepted", example: `{ "removed": 1204 }` }, { status: 404, description: "No such queue", example: `{ "code": "queue_not_found" }` }], }, ], }, { title: "Messages", endpoints: [ { method: "POST" as const, path: "/v1/queues/{queue}/messages", summary: "Send a message", description: "Adds a message to the queue. Send an `Idempotency-Key` header and a retry will return the first result instead of adding a duplicate.", parameters: [ { name: "queue", in: "path" as const, type: "string", required: true, description: "The queue name." }, { name: "Idempotency-Key", in: "header" as const, type: "string", description: "Any string up to 255 characters. Keys expire after 24 hours." }, { name: "body", in: "body" as const, type: "object", required: true, description: "Any JSON up to 256 KB." }, { name: "delay", in: "body" as const, type: "integer", description: "Seconds before the message becomes visible, up to 15 minutes.", default: "0" }, ], requestExample: `{\n "body": { "invoice": "inv_2041", "total": 4200 },\n "delay": 0\n}`, responses: [{ status: 201, description: "Message accepted", example: `{\n "id": "msg_7Hq2Zc",\n "queue": "invoices",\n "visibleAt": "2026-09-30T08:14:22Z"\n}` }, { status: 413, description: "Body is larger than 256 KB", example: `{ "code": "body_too_large" }` }], }, { method: "GET" as const, path: "/v1/queues/{queue}/messages", summary: "Receive messages", description: "Long-polls for up to `wait` seconds. Each message is hidden from other consumers until its visibility timeout ends.", parameters: [ { name: "queue", in: "path" as const, type: "string", required: true, description: "The queue name." }, { name: "maxMessages", in: "query" as const, type: "integer", description: "Up to 50 per call.", default: "1" }, { name: "wait", in: "query" as const, type: "integer", description: "Seconds to wait for a message, up to 20.", default: "0" }, ], responses: [{ status: 200, description: "Zero or more messages", example: `{\n "data": [\n { "id": "msg_7Hq2Zc", "receipt": "rcpt_91", "body": { "invoice": "inv_2041" }, "receives": 1 }\n ]\n}` }], }, { method: "DELETE" as const, path: "/v1/queues/{queue}/messages/{id}", summary: "Acknowledge a message", description: "Deletes a message once it has been processed. Use the receipt from the receive call.", parameters: [ { name: "queue", in: "path" as const, type: "string", required: true, description: "The queue name." }, { name: "id", in: "path" as const, type: "string", required: true, description: "The message id." }, { name: "receipt", in: "query" as const, type: "string", required: true, description: "Proves you hold the message right now." }, ], responses: [{ status: 204, description: "Deleted" }, { status: 410, description: "The visibility timeout ended; the message may already be with another consumer", example: `{ "code": "visibility_expired" }` }], }, ], }, ] export const snippets = { install: { Node: "npm install @tern/sdk", Python: "pip install tern", Go: "go get github.com/tern/tern-go", cURL: "# nothing to install" }, }components/ballmac/templates/docs/docs-theme.tsx// Ballmac UI: Docs template shell. https://ui.ballmac.com/templates/template-docs "use client" import * as React from "react" import { ChevronRight, Menu, Search } from "lucide-react" import { Command, CommandDialog, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ballmac/command" import { Kbd } from "@/components/ballmac/kbd" import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ballmac/sheet" import { nav, searchIndex, type DocsKind } from "@/components/ballmac/templates/docs/docs-data" import { docsMono, docsSans, docsSerif } from "@/components/ballmac/templates/docs/docs-fonts" import { cn } from "@/lib/utils" type DocsPage = "home" | "guide" | "reference" | "search" | "changelog" type DocsHrefs = Record<DocsPage, string> const defaultHrefs: DocsHrefs = { home: "/docs", guide: "/docs/guides", reference: "/docs/reference", search: "/docs/search", changelog: "/docs/changelog" } /** Tern's palette: a cool, quiet page with deep teal ink. Dark mode is the same page lit by a terminal. */ const docsCss = ` .docs-theme,body:not(:has([data-gallery])):has(.docs-theme){--background:oklch(0.985 0.006 210);--foreground:oklch(0.2 0.025 235);--card:oklch(1 0 0);--card-foreground:oklch(0.2 0.025 235);--popover:oklch(1 0 0);--popover-foreground:oklch(0.2 0.025 235);--primary:oklch(0.4 0.09 200);--primary-foreground:oklch(0.985 0.006 210);--secondary:oklch(0.955 0.012 210);--secondary-foreground:oklch(0.2 0.025 235);--muted:oklch(0.955 0.012 210);--muted-foreground:oklch(0.46 0.03 235);--accent:oklch(0.945 0.02 200);--accent-foreground:oklch(0.2 0.025 235);--border:oklch(0.2 0.025 235 / 12%);--input:oklch(0.2 0.025 235 / 18%);--ring:oklch(0.5 0.11 195);--surface:oklch(0.97 0.01 210);--destructive:oklch(0.52 0.21 27);--chart-1:oklch(0.5 0.11 190);--chart-2:oklch(0.5 0.15 265);--chart-3:oklch(0.62 0.15 75);--chart-4:oklch(0.55 0.15 150);--chart-5:oklch(0.55 0.2 20);--radius:0.75rem} .dark .docs-theme,.dark body:not(:has([data-gallery])):has(.docs-theme){--background:oklch(0.165 0.02 235);--foreground:oklch(0.95 0.01 205);--card:oklch(0.2 0.022 235);--card-foreground:oklch(0.95 0.01 205);--popover:oklch(0.22 0.024 235);--popover-foreground:oklch(0.95 0.01 205);--primary:oklch(0.8 0.11 190);--primary-foreground:oklch(0.18 0.03 235);--secondary:oklch(0.25 0.025 235);--secondary-foreground:oklch(0.95 0.01 205);--muted:oklch(0.25 0.025 235);--muted-foreground:oklch(0.72 0.03 215);--accent:oklch(0.28 0.03 225);--accent-foreground:oklch(0.95 0.01 205);--border:oklch(1 0 0 / 10%);--input:oklch(1 0 0 / 15%);--ring:oklch(0.8 0.11 190);--surface:oklch(0.19 0.021 235);--destructive:oklch(0.7 0.19 27);--chart-1:oklch(0.8 0.11 190);--chart-2:oklch(0.76 0.12 265);--chart-3:oklch(0.82 0.13 80);--chart-4:oklch(0.78 0.13 150);--chart-5:oklch(0.74 0.14 20)} body:not(:has([data-gallery])):has(.docs-theme){font-family:var(--docs-sans),ui-sans-serif,system-ui,sans-serif} @keyframes docs-packet{0%{transform:translateX(0);opacity:0}10%{opacity:1}90%{opacity:1}100%{transform:translateX(var(--docs-travel,10rem));opacity:0}} .docs-packet{animation:docs-packet 2.6s ease-in-out infinite} @media (prefers-reduced-motion:reduce){.docs-packet{animation:none;opacity:1}} ` const serif = "[font-family:var(--docs-serif),ui-serif,Georgia,serif] font-medium tracking-[-0.015em]" const mono = "[font-family:var(--docs-mono),ui-monospace,monospace]" const kindStyle: Record<DocsKind, string> = { Guide: "bg-chart-1/15", API: "bg-chart-2/15", Changelog: "bg-chart-3/20" } function Logo({ className }: { className?: string }) { return ( <span aria-hidden="true" className={cn("bg-primary text-primary-foreground inline-flex size-8 items-center justify-center rounded-lg", className)}> <svg viewBox="0 0 24 24" className="size-5" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 8h9a4 4 0 0 1 0 8H8" /><path d="m11 13-3 3 3 3" /></svg> </span> ) } type SidebarProps = { page: DocsPage; hrefs: DocsHrefs; onNavigate?: () => void } function SidebarNav({ page, hrefs, onNavigate }: SidebarProps) { return ( <nav aria-label="Documentation" className="grid gap-6 text-sm"> {nav.map((section) => ( <div key={section.title}> <h2 className="text-foreground mb-2 px-2 text-xs font-bold tracking-[0.12em] uppercase">{section.title}</h2> <ul className="grid gap-0.5"> {section.items.map((item) => { const current = item.page === page && !!item.current return ( <li key={item.title}> <a href={hrefs[item.page]} onClick={onNavigate} aria-current={current ? "page" : undefined} className="text-muted-foreground hover:text-foreground aria-[current=page]:bg-accent aria-[current=page]:text-foreground focus-visible:ring-ring/50 relative block rounded-md px-2 py-1.5 outline-none transition-colors focus-visible:ring-[3px] aria-[current=page]:font-semibold motion-reduce:transition-none" > {item.title} </a> </li> ) })} </ul> </div> ))} </nav> ) } type DocsShellProps = React.ComponentProps<"div"> & { /** The page being shown, so its nav link is marked current. */ page: DocsPage /** Override where pages live (used by previews). */ hrefs?: Partial<DocsHrefs> /** Show the left navigation. Landing and search pages turn it off. */ sidebar?: boolean } /** Tern's frame: a sticky header with the ⌘K search, a version picker, a sidebar and a footer. */ function DocsShell({ page, hrefs: overrides, sidebar = true, className, style, children, ...props }: DocsShellProps) { const hrefs = { ...defaultHrefs, ...overrides } const [searchOpen, setSearchOpen] = React.useState(false) const [menuOpen, setMenuOpen] = React.useState(false) React.useEffect(() => { const classes = [docsSerif.variable, docsSans.variable, docsMono.variable].filter(Boolean) document.body.classList.add(...classes) return () => document.body.classList.remove(...classes) }, []) return ( <div data-slot="docs" className={cn("docs-theme bg-background text-foreground relative min-h-dvh overflow-x-clip", docsSerif.variable, docsSans.variable, docsMono.variable, className)} style={{ fontFamily: "var(--docs-sans), ui-sans-serif, system-ui, sans-serif", ...style }} {...props} > <style>{docsCss}</style> <header className="bg-background/85 sticky top-0 z-40 border-b backdrop-blur-xl"> <div className="mx-auto flex h-16 max-w-[90rem] items-center gap-3 px-4 sm:px-6"> {sidebar && ( <Sheet open={menuOpen} onOpenChange={setMenuOpen}> <SheetTrigger className="hover:bg-accent focus-visible:ring-ring/50 inline-flex size-10 items-center justify-center rounded-lg outline-none focus-visible:ring-[3px] lg:hidden" aria-label="Open navigation"><Menu className="size-5" aria-hidden="true" /></SheetTrigger> <SheetContent side="start" className="docs-theme bg-background w-80 overflow-y-auto p-5"> <SheetHeader className="p-0 pb-4"><SheetTitle>Tern docs</SheetTitle><SheetDescription>Browse every guide and the API reference.</SheetDescription></SheetHeader> <SidebarNav page={page} hrefs={hrefs} onNavigate={() => setMenuOpen(false)} /> </SheetContent> </Sheet> )} <a href={hrefs.home} className="focus-visible:ring-ring/50 flex items-center gap-2.5 rounded-lg outline-none focus-visible:ring-[3px]"> <Logo /> <span className={cn("text-2xl", serif)}>Tern</span> <span className="text-muted-foreground hidden border-s ps-2.5 text-sm font-semibold sm:inline">Docs</span> </a> <nav aria-label="Main" className="ms-4 hidden items-center gap-1 text-sm font-medium md:flex"> {([["guide", "Guides"], ["reference", "API reference"], ["changelog", "Changelog"]] as const).map(([key, label]) => ( <a key={key} href={hrefs[key]} aria-current={page === key ? "page" : undefined} className="text-muted-foreground hover:text-foreground aria-[current=page]:text-foreground focus-visible:ring-ring/50 rounded-md px-3 py-2 outline-none transition-colors focus-visible:ring-[3px] aria-[current=page]:font-semibold motion-reduce:transition-none">{label}</a> ))} </nav> <div className="ms-auto flex items-center gap-2"> <button type="button" onClick={() => setSearchOpen(true)} className="bg-surface hover:bg-accent focus-visible:ring-ring/50 text-muted-foreground inline-flex h-10 items-center gap-2 rounded-lg border px-3 text-sm outline-none transition-colors focus-visible:ring-[3px] sm:w-64 motion-reduce:transition-none" aria-label="Search the docs"> <Search className="size-4 shrink-0" aria-hidden="true" /> <span className="hidden flex-1 text-start sm:inline">Search the docs</span> <span className="hidden items-center gap-1 sm:inline-flex" aria-hidden="true"><Kbd>⌘</Kbd><Kbd>K</Kbd></span> </button> <label className="sr-only" htmlFor="docs-version">API version</label> <select id="docs-version" defaultValue="v3" className="bg-background focus-visible:ring-ring/50 h-10 rounded-lg border px-2.5 text-sm font-medium outline-none focus-visible:ring-[3px]"> <option value="v3">v3.2</option> <option value="v2">v2.9</option> </select> </div> </div> </header> <CommandDialog open={searchOpen} onOpenChange={setSearchOpen} title="Search the docs" description="Type to search guides, the API reference and the changelog."> <Command> <CommandInput placeholder="Search guides, endpoints and releases" /> <CommandList> <CommandEmpty>No results. Try “queue” or “retry”.</CommandEmpty> {(["Guide", "API", "Changelog"] as const).map((kind) => ( <CommandGroup key={kind} heading={kind === "API" ? "API reference" : kind === "Guide" ? "Guides" : "Releases"}> {searchIndex.filter((e) => e.kind === kind).map((e) => ( <CommandItem key={e.title} value={`${e.title} ${e.path} ${e.summary}`} onSelect={() => { setSearchOpen(false); window.location.href = hrefs[e.page] }}> <span className="grid min-w-0"><span className="truncate font-medium">{e.title}</span><span className={cn("text-muted-foreground truncate text-xs", kind === "API" && mono)}>{e.path}</span></span> </CommandItem> ))} </CommandGroup> ))} </CommandList> </Command> </CommandDialog> {sidebar ? ( <div className="mx-auto grid max-w-[90rem] grid-cols-[minmax(0,1fr)] lg:grid-cols-[16rem_minmax(0,1fr)]"> <aside className="sticky top-16 hidden h-[calc(100dvh-4rem)] overflow-y-auto border-e p-6 pe-4 lg:block" aria-label="Sidebar"> <SidebarNav page={page} hrefs={hrefs} /> </aside> <div className="min-w-0">{children}</div> </div> ) : ( children )} <footer className="border-t"> <div className="mx-auto grid max-w-[90rem] gap-8 px-4 py-12 sm:px-6 md:grid-cols-[1.4fr_1fr_1fr_1fr]"> <div><a href={hrefs.home} className="flex items-center gap-2.5"><Logo className="size-7" /><span className={cn("text-xl", serif)}>Tern</span></a><p className="text-muted-foreground mt-3 max-w-xs text-sm text-pretty">Queues that never lose a message. Built by people who have been paged at 3 a.m.</p></div> {[ ["Learn", [["Quickstart", hrefs.guide], ["Concepts", hrefs.guide], ["Guides", hrefs.guide]]], ["Build", [["API reference", hrefs.reference], ["SDKs", hrefs.reference], ["Changelog", hrefs.changelog]]], ["Help", [["Search", hrefs.search], ["Status", hrefs.home], ["Contact support", hrefs.home]]], ].map(([title, items]) => ( <div key={title as string}><h2 className="text-xs font-bold tracking-[0.12em] uppercase">{title as string}</h2><ul className="mt-3 grid gap-2 text-sm">{(items as string[][]).map(([l, h]) => <li key={l}><a href={h} className="text-muted-foreground hover:text-foreground">{l}</a></li>)}</ul></div> ))} </div> <p className="text-muted-foreground mx-auto max-w-[90rem] border-t px-4 py-5 text-xs sm:px-6">© 2026 Tern Systems. All systems operational.</p> </footer> </div> ) } /** A small crumb row: "Get started › Send your first message". */ function Crumbs({ items }: { items: string[] }) { return ( <nav aria-label="Breadcrumb" className="text-muted-foreground flex flex-wrap items-center gap-1 text-sm"> {items.map((it, i) => ( <React.Fragment key={it}> {i > 0 && <ChevronRight className="size-3.5 rtl:rotate-180" aria-hidden="true" />} <span aria-current={i === items.length - 1 ? "page" : undefined} className={i === items.length - 1 ? "text-foreground font-medium" : undefined}>{it}</span> </React.Fragment> ))} </nav> ) } export { Crumbs, DocsShell, Logo as DocsLogo, defaultHrefs as docsDefaultHrefs, kindStyle as docsKindStyle, mono as docsMonoClass, serif as docsSerifClass, type DocsHrefs, type DocsPage, type DocsShellProps }components/ballmac/templates/docs/docs-home.tsx// Ballmac UI: Docs home page. https://ui.ballmac.com/templates/template-docs "use client" import * as React from "react" import { ArrowRight, BookOpen, Code2, History, Rocket, Search } from "lucide-react" import { SnippetTabs } from "@/components/ballmac/snippet-tabs" import { releases, searchIndex, snippets } from "@/components/ballmac/templates/docs/docs-data" import { DocsShell, docsMonoClass, docsSerifClass, type DocsHrefs } from "@/components/ballmac/templates/docs/docs-theme" import { cn } from "@/lib/utils" const paths = [ { icon: Rocket, title: "Quickstart", body: "Send and receive your first message in about seven minutes.", key: "guide" as const, tint: "bg-chart-1/15" }, { icon: BookOpen, title: "Guides", body: "Retries, dead letters, schedules and idempotent consumers, explained with code.", key: "guide" as const, tint: "bg-chart-2/15" }, { icon: Code2, title: "API reference", body: "Every endpoint, parameter and error code, with examples in four languages.", key: "reference" as const, tint: "bg-chart-3/20" }, { icon: History, title: "Changelog", body: "What shipped, what changed and what to do about it.", key: "changelog" as const, tint: "bg-chart-4/15" }, ] /** Producer, queue and consumer with packets travelling between them. Decorative; the sentence below carries the meaning. */ function Flow() { const box = "bg-card grid h-20 w-24 shrink-0 place-items-center rounded-xl border text-center text-xs font-semibold shadow-xs sm:w-32 sm:text-sm" return ( <figure className="mx-auto w-full max-w-xl" aria-label="How a message travels"> <div aria-hidden="true" className="flex items-center"> <div className={box}><span>Producer<span className={cn("text-muted-foreground block text-xs font-normal", docsMonoClass)}>your app</span></span></div> <div className="relative h-px min-w-6 flex-1 border-t-2 border-dashed [--docs-travel:1.75rem] sm:[--docs-travel:5rem]"><span className="docs-packet bg-chart-1 absolute -top-[7px] start-0 size-3 rounded-full" /></div> <div className={cn(box, "bg-primary text-primary-foreground border-primary")}><span>Queue<span className={cn("block text-xs font-normal opacity-100", docsMonoClass)}>invoices</span></span></div> <div className="relative h-px min-w-6 flex-1 border-t-2 border-dashed [--docs-travel:1.75rem] sm:[--docs-travel:5rem]"><span className="docs-packet bg-chart-3 absolute -top-[7px] start-0 size-3 rounded-full" style={{ animationDelay: "1.1s" }} /></div> <div className={box}><span>Consumer<span className={cn("text-muted-foreground block text-xs font-normal", docsMonoClass)}>worker</span></span></div> </div> <figcaption className="text-muted-foreground mt-4 text-center text-sm">A producer sends, Tern holds the message until a consumer acknowledges it.</figcaption> </figure> ) } type DocsHomeProps = React.ComponentProps<"div"> & { hrefs?: Partial<DocsHrefs> } /** The Docs landing page: a search-first hero, four paths in, install tabs and the latest releases. */ function DocsHome({ hrefs, ...props }: DocsHomeProps) { const link = { ...{ home: "/docs", guide: "/docs/guides", reference: "/docs/reference", search: "/docs/search", changelog: "/docs/changelog" }, ...hrefs } return ( <DocsShell page="home" hrefs={hrefs} sidebar={false} {...props}> <main> <section aria-labelledby="dh-title" className="relative isolate overflow-hidden border-b"> <div aria-hidden="true" className="absolute inset-0 -z-10 [background-image:radial-gradient(var(--border)_1.2px,transparent_1.2px)] [background-size:22px_22px] [mask-image:linear-gradient(to_bottom,black,transparent_85%)]" /> <div className="mx-auto max-w-5xl px-4 pt-16 pb-14 text-center sm:px-6 sm:pt-24"> <p className={cn("bg-accent text-accent-foreground inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold", docsMonoClass)}><span className="bg-chart-4 size-1.5 rounded-full" aria-hidden="true" />v3.2 · message replay is here</p> <h1 id="dh-title" className={cn("mx-auto mt-6 max-w-3xl text-[clamp(2.8rem,7vw,5.5rem)] leading-[1.02] text-balance", docsSerifClass)}>Queues that <em className="text-primary">never lose</em> a message.</h1> <p className="text-muted-foreground mx-auto mt-5 max-w-xl text-lg text-pretty">Everything you need to send, receive and retry messages at any scale, written by the people who run the queues.</p> <form action={link.search} method="get" role="search" className="mx-auto mt-9 flex max-w-xl items-center gap-2"> <label htmlFor="dh-q" className="sr-only">Search the docs</label> <div className="bg-card focus-within:ring-ring/50 relative flex-1 rounded-xl border shadow-sm focus-within:ring-[3px]"> <Search className="text-muted-foreground pointer-events-none absolute top-1/2 start-4 size-5 -translate-y-1/2" aria-hidden="true" /> <input id="dh-q" name="q" type="search" placeholder="Search: retries, idempotency, dead letters…" className="h-14 w-full rounded-xl bg-transparent pe-4 ps-12 text-base outline-none" /> </div> <button type="submit" className="bg-primary text-primary-foreground focus-visible:ring-ring/50 h-14 rounded-xl px-6 text-base font-semibold outline-none transition-opacity hover:opacity-90 focus-visible:ring-[3px]">Search</button> </form> <p className="text-muted-foreground mt-4 text-sm">Popular: {["Retries", "Dead letters", "Idempotency"].map((t, i) => <React.Fragment key={t}>{i > 0 && ", "}<a href={`${link.search}?q=${t.toLowerCase()}`} className="text-foreground underline underline-offset-4">{t}</a></React.Fragment>)}</p> <div className="mt-14"><Flow /></div> </div> </section> <section aria-labelledby="dh-paths" className="mx-auto max-w-6xl px-4 py-16 sm:px-6"> <h2 id="dh-paths" className="sr-only">Where to start</h2> <ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> {paths.map((p) => ( <li key={p.title}> <a href={link[p.key]} className="bg-card hover:border-primary/50 focus-visible:ring-ring/50 group flex h-full flex-col rounded-2xl border p-6 outline-none transition-[border-color,transform] hover:-translate-y-0.5 focus-visible:ring-[3px] motion-reduce:transition-none motion-reduce:hover:translate-y-0"> <span className={cn("inline-flex size-11 items-center justify-center rounded-xl", p.tint)}><p.icon className="size-5" aria-hidden="true" /></span> <span className={cn("mt-5 text-2xl", docsSerifClass)}>{p.title}</span> <span className="text-muted-foreground mt-2 flex-1 text-sm text-pretty">{p.body}</span> <span className="text-primary mt-5 inline-flex items-center gap-1 text-sm font-semibold">Open <ArrowRight className="size-4 transition-transform group-hover:translate-x-1 motion-reduce:transition-none rtl:rotate-180 rtl:group-hover:-translate-x-1" aria-hidden="true" /></span> </a> </li> ))} </ul> </section> <section aria-labelledby="dh-install" className="bg-surface border-y"> <div className="mx-auto grid max-w-6xl items-center gap-10 px-4 py-16 sm:px-6 lg:grid-cols-[1fr_1.1fr]"> <div> <h2 id="dh-install" className={cn("text-4xl text-balance sm:text-5xl", docsSerifClass)}>One command, then a message.</h2> <p className="text-muted-foreground mt-4 text-lg text-pretty">Official SDKs for Node, Python and Go, and a plain HTTP API for everything else. Pick a language once and every code sample in the docs follows you.</p> <ul className="mt-6 grid gap-2 text-sm">{["Retries and backoff built in", "Typed clients, generated from the API", "Same behaviour in every language"].map((t) => <li key={t} className="flex items-center gap-2"><span className="bg-chart-4 size-1.5 rounded-full" aria-hidden="true" />{t}</li>)}</ul> </div> <SnippetTabs storageKey="tern-lang" title="Install" snippets={(Object.entries(snippets.install) as [string, string][]).map(([label, code]) => ({ label, code, language: "bash" }))} /> </div> </section> <section aria-labelledby="dh-pop" className="mx-auto grid max-w-6xl gap-12 px-4 py-16 sm:px-6 lg:grid-cols-2"> <div> <h2 id="dh-pop" className={cn("text-3xl", docsSerifClass)}>Most read</h2> <ol className="mt-5 divide-y border-y"> {searchIndex.filter((e) => e.kind === "Guide").slice(0, 5).map((e, i) => ( <li key={e.title}> <a href={link[e.page]} className="hover:bg-accent focus-visible:ring-ring/50 group grid grid-cols-[2rem_1fr_auto] items-center gap-3 px-2 py-4 outline-none focus-visible:ring-[3px] focus-visible:ring-inset"> <span className={cn("text-muted-foreground text-sm", docsMonoClass)}>{String(i + 1).padStart(2, "0")}</span> <span className="min-w-0"><span className="block font-semibold">{e.title}</span><span className="text-muted-foreground block truncate text-sm">{e.summary}</span></span> <ArrowRight className="text-muted-foreground size-4 transition-transform group-hover:translate-x-1 motion-reduce:transition-none rtl:rotate-180 rtl:group-hover:-translate-x-1" aria-hidden="true" /> </a> </li> ))} </ol> </div> <div> <div className="flex items-end justify-between"><h2 className={cn("text-3xl", docsSerifClass)}>What’s new</h2><a href={link.changelog} className="text-primary text-sm font-semibold underline-offset-4 hover:underline">All releases</a></div> <ul className="mt-5 grid gap-4"> {releases.slice(0, 3).map((r) => ( <li key={r.version} className="bg-card rounded-2xl border p-5"> <div className="flex items-center gap-3"><span className={cn("bg-secondary rounded-md px-2 py-0.5 text-xs font-bold", docsMonoClass)}>{r.version}</span><span className="text-muted-foreground text-xs">{new Date(r.date + "T00:00:00Z").toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" })}</span></div> <h3 className="mt-3 text-lg font-semibold">{r.title}</h3> <p className="text-muted-foreground mt-1 text-sm text-pretty">{r.body}</p> </li> ))} </ul> </div> </section> <section aria-labelledby="dh-help" className="mx-auto max-w-6xl px-4 pb-8 sm:px-6"> <div className="bg-primary text-primary-foreground flex flex-col items-start justify-between gap-6 rounded-3xl p-8 sm:flex-row sm:items-center sm:p-12"> <div><h2 id="dh-help" className={cn("text-3xl sm:text-4xl", docsSerifClass)}>Stuck? A human will answer.</h2><p className="mt-2 max-w-lg text-pretty">Average first reply on weekdays is eleven minutes. Bring your message id and we will find it.</p></div> <a href={link.search} className="bg-primary-foreground text-primary focus-visible:ring-ring inline-flex h-12 shrink-0 items-center gap-2 rounded-xl px-6 font-semibold outline-none focus-visible:ring-[3px]">Ask support <ArrowRight className="size-4 rtl:rotate-180" aria-hidden="true" /></a> </div> </section> </main> </DocsShell> ) } export { DocsHome, type DocsHomeProps }components/ballmac/templates/docs/docs-guide.tsx// Ballmac UI: Docs guide page. https://ui.ballmac.com/templates/template-docs "use client" import * as React from "react" import { ArrowLeft, ArrowRight, Check, Clock, ThumbsDown, ThumbsUp } from "lucide-react" import { Callout } from "@/components/ballmac/callout" import { CodeBlock } from "@/components/ballmac/code-block" import { CopyButton } from "@/components/ballmac/copy-button" import { InstallTabs } from "@/components/ballmac/install-tabs" import { SnippetTabs } from "@/components/ballmac/snippet-tabs" import { TableOfContents } from "@/components/ballmac/table-of-contents" import { Crumbs, DocsShell, docsMonoClass, docsSerifClass, type DocsHrefs } from "@/components/ballmac/templates/docs/docs-theme" import { cn } from "@/lib/utils" const toc = [ { id: "before", title: "Before you begin", level: 2 }, { id: "install", title: "Install the SDK", level: 2 }, { id: "create", title: "Create a queue", level: 2 }, { id: "send", title: "Send a message", level: 2 }, { id: "receive", title: "Receive and acknowledge", level: 2 }, { id: "next", title: "Next steps", level: 2 }, ] const createSnippets = [ { label: "Node", language: "typescript" as const, code: `const { Tern } = require("@tern/sdk")\n\nconst tern = new Tern({ apiKey: process.env.TERN_KEY, region: "eu" })\n\nawait tern.queues.create({\n name: "invoices",\n visibilityTimeout: 60,\n})` }, { label: "Python", language: "python" as const, code: `import os\nfrom tern import Tern\n\ntern = Tern(api_key=os.environ["TERN_KEY"], region="eu")\n\ntern.queues.create(\n name="invoices",\n visibility_timeout=60,\n)` }, { label: "Go", language: "go" as const, code: `client := tern.New(os.Getenv("TERN_KEY"), tern.WithRegion("eu"))\n\n_, err := client.Queues.Create(ctx, tern.CreateQueue{\n Name: "invoices",\n VisibilityTimeout: 60,\n})` }, { label: "cURL", language: "bash" as const, code: `curl https://eu.api.tern.dev/v1/queues \\\n -H "Authorization: Bearer $TERN_KEY" \\\n -d '{"name":"invoices","visibilityTimeout":60}'` }, ] const sendSnippets = [ { label: "Node", language: "typescript" as const, code: `const message = await tern.queue("invoices").send(\n { invoice: "inv_2041", total: 4200 },\n { idempotencyKey: "inv_2041" }\n)\n\nconsole.log(message.id) // msg_7Hq2Zc` }, { label: "Python", language: "python" as const, code: `message = tern.queue("invoices").send(\n {"invoice": "inv_2041", "total": 4200},\n idempotency_key="inv_2041",\n)\n\nprint(message.id) # msg_7Hq2Zc` }, { label: "Go", language: "go" as const, code: `msg, _ := client.Queue("invoices").Send(ctx,\n map[string]any{"invoice": "inv_2041", "total": 4200},\n tern.IdempotencyKey("inv_2041"))\n\nfmt.Println(msg.ID) // msg_7Hq2Zc` }, { label: "cURL", language: "bash" as const, code: `curl https://eu.api.tern.dev/v1/queues/invoices/messages \\\n -H "Authorization: Bearer $TERN_KEY" \\\n -H "Idempotency-Key: inv_2041" \\\n -d '{"body":{"invoice":"inv_2041","total":4200}}'` }, ] const consumeSnippets = [ { label: "Node", language: "typescript" as const, code: `for await (const msg of tern.queue("invoices").consume({ wait: 20 })) {\n await chargeCustomer(msg.body)\n await msg.ack() // delete it, or it comes back\n}` }, { label: "Python", language: "python" as const, code: `for msg in tern.queue("invoices").consume(wait=20):\n charge_customer(msg.body)\n msg.ack() # delete it, or it comes back` }, { label: "Go", language: "go" as const, code: `for msg := range client.Queue("invoices").Consume(ctx, tern.Wait(20)) {\n chargeCustomer(msg.Body)\n msg.Ack() // delete it, or it comes back\n}` }, { label: "cURL", language: "bash" as const, code: `curl "https://eu.api.tern.dev/v1/queues/invoices/messages?wait=20" \\\n -H "Authorization: Bearer $TERN_KEY"` }, ] function Step({ n, title, id, children, last }: { n: number; title: string; id: string; children: React.ReactNode; last?: boolean }) { return ( <section aria-labelledby={id} className="grid grid-cols-[2.5rem_minmax(0,1fr)] gap-x-4 sm:gap-x-5"> <div className="flex flex-col items-center"> <span aria-hidden="true" className={cn("bg-primary text-primary-foreground flex size-10 items-center justify-center rounded-full text-sm font-bold", docsMonoClass)}>{n}</span> {!last && <span aria-hidden="true" className="bg-border mt-2 w-px flex-1" />} </div> <div className={cn("grid min-w-0 gap-4", !last && "pb-12")}> <h2 id={id} className={cn("scroll-mt-24 pt-1 text-3xl", docsSerifClass)}>{title}</h2> {children} </div> </section> ) } function Helpful() { const [vote, setVote] = React.useState<"yes" | "no" | null>(null) const [note, setNote] = React.useState("") const [sent, setSent] = React.useState(false) const btn = "hover:bg-accent focus-visible:ring-ring/50 aria-pressed:border-primary aria-pressed:bg-accent inline-flex h-10 items-center gap-2 rounded-lg border px-4 text-sm font-semibold outline-none focus-visible:ring-[3px]" return ( <section aria-labelledby="dg-help" className="bg-surface rounded-2xl border p-6"> <h2 id="dg-help" className="font-semibold">Was this page helpful?</h2> {sent || vote === "yes" ? ( <p role="status" className="mt-3 flex items-center gap-2 text-sm"><Check className="text-chart-4 size-4" aria-hidden="true" />{sent ? "Thanks. We read every note." : "Glad it helped. Thank you."}</p> ) : ( <> <div className="mt-3 flex gap-2"> <button type="button" className={btn} onClick={() => setVote("yes")}><ThumbsUp className="size-4" aria-hidden="true" />Yes</button> <button type="button" className={btn} aria-pressed={vote === "no"} onClick={() => setVote("no")}><ThumbsDown className="size-4" aria-hidden="true" />No</button> </div> {vote === "no" && ( <form className="mt-4 grid gap-3" onSubmit={(e) => { e.preventDefault(); setSent(true) }}> <label htmlFor="dg-note" className="text-sm font-medium">What was missing or confusing?</label> <textarea id="dg-note" value={note} onChange={(e) => setNote(e.target.value)} rows={3} className="bg-background focus-visible:ring-ring/50 w-full rounded-lg border p-3 text-sm outline-none focus-visible:ring-[3px]" /> <button type="submit" className="bg-primary text-primary-foreground focus-visible:ring-ring/50 h-10 w-fit rounded-lg px-5 text-sm font-semibold outline-none focus-visible:ring-[3px]">Send feedback</button> </form> )} </> )} </section> ) } type DocsGuideProps = React.ComponentProps<"div"> & { hrefs?: Partial<DocsHrefs> } /** A Docs guide: breadcrumbs, numbered steps with synced language tabs, callouts, an on-this-page list and a feedback box. */ function DocsGuide({ hrefs, ...props }: DocsGuideProps) { const link = { guide: "/docs/guides", reference: "/docs/reference", ...hrefs } return ( <DocsShell page="guide" hrefs={hrefs} {...props}> <div className="grid grid-cols-[minmax(0,1fr)] gap-12 px-4 py-10 sm:px-8 xl:grid-cols-[minmax(0,1fr)_14rem]"> <main className="mx-auto w-full min-w-0 max-w-3xl"> <Crumbs items={["Get started", "Send your first message"]} /> <h1 className={cn("mt-5 text-[clamp(2.4rem,5vw,3.8rem)] leading-[1.05] text-balance", docsSerifClass)}>Send your first message</h1> <p className="text-muted-foreground mt-4 text-xl text-pretty">Create a queue, put a message on it and take it off again. By the end you will have seen the whole loop that everything else is built on.</p> <p className="text-muted-foreground mt-5 flex items-center gap-4 text-sm"><span className="inline-flex items-center gap-1.5"><Clock className="size-4" aria-hidden="true" />7 min read</span><span>Updated Sep 24, 2026</span></p> <div className="mt-10"> <Step n={1} id="before" title="Before you begin"> <p className="text-pretty">You need a Tern account and a test API key. Test keys only touch test queues, so nothing here can affect production.</p> <div className="bg-card flex items-center justify-between gap-3 rounded-xl border p-3 ps-4"> <div className="min-w-0"><p className="text-muted-foreground text-xs">Your test key</p><p className={cn("truncate text-sm", docsMonoClass)}>tern_test_4fJ2kQ9xN7mB1c</p></div> <CopyButton value="tern_test_4fJ2kQ9xN7mB1c" ariaLabel="Copy the test key" variant="outline" size="sm" /> </div> <Callout kind="note" title="Pick a region once">Queues live in one region. Use <code className={docsMonoClass}>eu</code>, <code className={docsMonoClass}>us</code> or <code className={docsMonoClass}>ap</code> and keep it the same in every client.</Callout> </Step> <Step n={2} id="install" title="Install the SDK"> <p className="text-pretty">Add the Node package with your package manager. Using Python or Go? Switch the tabs below and every sample on this page follows.</p> <InstallTabs storageKey="tern-pm" commands={{ npm: "npm install @tern/sdk", pnpm: "pnpm add @tern/sdk", yarn: "yarn add @tern/sdk", bun: "bun add @tern/sdk" }} /> </Step> <Step n={3} id="create" title="Create a queue"> <p className="text-pretty">A queue holds messages until a consumer acknowledges them. The visibility timeout is how long a received message stays hidden before Tern offers it to someone else.</p> <SnippetTabs storageKey="tern-lang" title="Create a queue" lineNumbers snippets={createSnippets} /> <Callout kind="tip" title="Names are permanent">A queue cannot be renamed. Pick something you will still like in a year, such as <code className={docsMonoClass}>invoices</code> rather than <code className={docsMonoClass}>test2</code>.</Callout> </Step> <Step n={4} id="send" title="Send a message"> <p className="text-pretty">The body can be any JSON up to 256 KB. Pass an idempotency key and a network retry returns the first message instead of adding a second.</p> <SnippetTabs storageKey="tern-lang" title="Send a message" lineNumbers snippets={sendSnippets} /> <CodeBlock filename="response.json" language="json" code={`{\n "id": "msg_7Hq2Zc",\n "queue": "invoices",\n "visibleAt": "2026-09-30T08:14:22Z"\n}`} highlight={[2]} /> </Step> <Step n={5} id="receive" title="Receive and acknowledge" last> <p className="text-pretty">Consuming is a loop: wait for a message, do the work, then acknowledge. Acknowledging deletes the message. If your code crashes first, the visibility timeout ends and Tern hands the message to the next consumer.</p> <SnippetTabs storageKey="tern-lang" title="Consume messages" lineNumbers snippets={consumeSnippets} /> <Callout kind="caution" title="Acknowledge only after the work succeeds">Delivery is at least once. A message can arrive twice, so make your handler safe to run twice. The idempotent consumers guide shows how.</Callout> </Step> </div> <section aria-labelledby="next" className="mt-14"> <h2 id="next" className={cn("scroll-mt-24 text-3xl", docsSerifClass)}>Next steps</h2> <ul className="mt-5 grid gap-4 sm:grid-cols-2"> {[["Delivery guarantees", "What at-least-once really means for your code.", link.guide], ["Dead-letter queues", "Park messages that keep failing and replay them later.", link.guide], ["Idempotent consumers", "Make retries safe.", link.guide], ["API reference", "Every endpoint, with examples.", link.reference]].map(([t, d, h]) => ( <li key={t}><a href={h} className="bg-card hover:border-primary/50 focus-visible:ring-ring/50 group block h-full rounded-xl border p-5 outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none"><span className="flex items-center justify-between font-semibold">{t}<ArrowRight className="size-4 transition-transform group-hover:translate-x-1 motion-reduce:transition-none rtl:rotate-180 rtl:group-hover:-translate-x-1" aria-hidden="true" /></span><span className="text-muted-foreground mt-1 block text-sm">{d}</span></a></li> ))} </ul> </section> <div className="mt-12"><Helpful /></div> <nav aria-label="Previous and next page" className="mt-8 grid gap-4 sm:grid-cols-2"> <a href={link.guide} className="hover:border-primary/50 focus-visible:ring-ring/50 group rounded-xl border p-5 outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none"><span className="text-muted-foreground flex items-center gap-1 text-xs"><ArrowLeft className="size-3.5 rtl:rotate-180" aria-hidden="true" />Previous</span><span className="mt-1 block font-semibold">Core concepts</span></a> <a href={link.guide} className="hover:border-primary/50 focus-visible:ring-ring/50 group rounded-xl border p-5 text-end outline-none transition-colors focus-visible:ring-[3px] motion-reduce:transition-none"><span className="text-muted-foreground flex items-center justify-end gap-1 text-xs">Next<ArrowRight className="size-3.5 rtl:rotate-180" aria-hidden="true" /></span><span className="mt-1 block font-semibold">Delivery guarantees</span></a> </nav> </main> <aside className="hidden xl:block" aria-label="On this page"><div className="sticky top-24"><TableOfContents items={toc} title="On this page" offset={96} /></div></aside> </div> </DocsShell> ) } export { DocsGuide, type DocsGuideProps }components/ballmac/templates/docs/docs-reference.tsx// Ballmac UI: Docs API reference page. https://ui.ballmac.com/templates/template-docs "use client" import * as React from "react" import { Search } from "lucide-react" import { ApiEndpoint } from "@/components/ballmac/api-endpoint" import { Callout } from "@/components/ballmac/callout" import { CopyButton } from "@/components/ballmac/copy-button" import { TableOfContents } from "@/components/ballmac/table-of-contents" import { endpointGroups } from "@/components/ballmac/templates/docs/docs-data" import { Crumbs, DocsShell, docsMonoClass, docsSerifClass, type DocsHrefs } from "@/components/ballmac/templates/docs/docs-theme" import { cn } from "@/lib/utils" const methods = ["All", "GET", "POST", "DELETE"] as const const limits = [["Requests", "600 per minute per key"], ["Message size", "256 KB"], ["Batch receive", "50 messages"], ["Long poll", "20 seconds"], ["Idempotency keys", "24 hours"]] const toc = [ { id: "auth", title: "Authentication", level: 2 }, { id: "limits", title: "Limits", level: 2 }, { id: "queues", title: "Queues", level: 2 }, { id: "messages", title: "Messages", level: 2 }, ] type DocsReferenceProps = React.ComponentProps<"div"> & { hrefs?: Partial<DocsHrefs> } /** The API reference: authentication, limits and expandable endpoint cards that filter by method or text. */ function DocsReference({ hrefs, ...props }: DocsReferenceProps) { const [method, setMethod] = React.useState<(typeof methods)[number]>("All") const [query, setQuery] = React.useState("") const q = query.trim().toLowerCase() const groups = endpointGroups .map((g) => ({ ...g, endpoints: g.endpoints.filter((e) => (method === "All" || e.method === method) && (!q || `${e.summary} ${e.path} ${e.description}`.toLowerCase().includes(q))) })) .filter((g) => g.endpoints.length > 0) const total = groups.reduce((n, g) => n + g.endpoints.length, 0) return ( <DocsShell page="reference" hrefs={hrefs} {...props}> <div className="grid grid-cols-[minmax(0,1fr)] gap-12 px-4 py-10 sm:px-8 xl:grid-cols-[minmax(0,1fr)_14rem]"> <main className="mx-auto w-full min-w-0 max-w-3xl"> <Crumbs items={["API reference", "Overview"]} /> <h1 className={cn("mt-5 text-[clamp(2.4rem,5vw,3.8rem)] leading-[1.05] text-balance", docsSerifClass)}>API reference</h1> <p className="text-muted-foreground mt-4 text-xl text-pretty">A small, regular HTTP API. Every endpoint takes and returns JSON, and every SDK method maps to exactly one of them.</p> <div className="bg-card mt-6 flex items-center justify-between gap-3 rounded-xl border p-3 ps-4"> <div className="min-w-0"><p className="text-muted-foreground text-xs">Base URL</p><p className={cn("truncate text-sm", docsMonoClass)}>https://eu.api.tern.dev</p></div> <CopyButton value="https://eu.api.tern.dev" ariaLabel="Copy the base URL" variant="outline" size="sm" /> </div> <section aria-labelledby="auth" className="mt-12"> <h2 id="auth" className={cn("scroll-mt-24 text-3xl", docsSerifClass)}>Authentication</h2> <p className="mt-3 text-pretty">Send your key as a bearer token. Test keys start with <code className={docsMonoClass}>tern_test_</code> and live keys with <code className={docsMonoClass}>tern_live_</code>.</p> <pre tabIndex={0} role="region" aria-label="Authorization header" className={cn("bg-muted/60 mt-4 overflow-x-auto rounded-xl border p-4 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50", docsMonoClass)}>Authorization: Bearer tern_live_••••••••</pre> <div className="mt-4"><Callout kind="caution" title="Keep live keys on the server">Never ship a live key in a browser or a mobile app. Create a restricted key that can only send messages if a client must call Tern directly.</Callout></div> </section> <section aria-labelledby="limits" className="mt-12"> <h2 id="limits" className={cn("scroll-mt-24 text-3xl", docsSerifClass)}>Limits</h2> <dl className="mt-4 divide-y rounded-xl border"> {limits.map(([k, v]) => <div key={k} className="grid grid-cols-2 gap-4 px-4 py-3 text-sm"><dt className="text-muted-foreground">{k}</dt><dd className={cn("font-medium", docsMonoClass)}>{v}</dd></div>)} </dl> </section> <div className="mt-14 flex flex-wrap items-center gap-3" role="group" aria-label="Filter endpoints"> <div className="relative min-w-52 flex-1"> <label htmlFor="dr-q" className="sr-only">Filter endpoints</label> <Search className="text-muted-foreground pointer-events-none absolute top-1/2 start-3 size-4 -translate-y-1/2" aria-hidden="true" /> <input id="dr-q" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Filter endpoints" className="bg-card focus-visible:ring-ring/50 h-10 w-full rounded-lg border pe-3 ps-9 text-sm outline-none focus-visible:ring-[3px]" /> </div> <div className="flex gap-1.5"> {methods.map((m) => ( <button key={m} type="button" aria-pressed={method === m} onClick={() => setMethod(m)} className={cn("hover:bg-accent focus-visible:ring-ring/50 aria-pressed:bg-primary aria-pressed:text-primary-foreground h-10 rounded-lg border px-3 text-xs font-bold outline-none focus-visible:ring-[3px]", docsMonoClass)}>{m}</button> ))} </div> </div> <p role="status" className="text-muted-foreground mt-3 text-sm">{total} {total === 1 ? "endpoint" : "endpoints"}</p> {groups.map((g) => ( <section key={g.title} aria-labelledby={g.title.toLowerCase()} className="mt-8"> <h2 id={g.title.toLowerCase()} className={cn("scroll-mt-24 text-3xl", docsSerifClass)}>{g.title}</h2> <div className="mt-5 grid gap-4"> {g.endpoints.map((e, i) => ( <ApiEndpoint key={e.method + e.path} method={e.method} path={e.path} summary={e.summary} description={e.description} baseUrl="https://eu.api.tern.dev" auth="Bearer key" parameters={e.parameters} requestExample={e.requestExample} responses={e.responses} defaultOpen={i === 0 && g.title === groups[0]?.title} /> ))} </div> </section> ))} {total === 0 && <div className="bg-surface mt-8 rounded-2xl border p-10 text-center"><p className={cn("text-2xl", docsSerifClass)}>No endpoint matches “{query}”.</p><p className="text-muted-foreground mt-2 text-sm">Try a shorter word, or clear the method filter.</p><button type="button" onClick={() => { setQuery(""); setMethod("All") }} className="bg-primary text-primary-foreground focus-visible:ring-ring/50 mt-5 h-10 rounded-lg px-5 text-sm font-semibold outline-none focus-visible:ring-[3px]">Clear filters</button></div>} </main> <aside className="hidden xl:block" aria-label="On this page"><div className="sticky top-24"><TableOfContents items={toc} title="On this page" offset={96} /></div></aside> </div> </DocsShell> ) } export { DocsReference, type DocsReferenceProps }components/ballmac/templates/docs/docs-search.tsx// Ballmac UI: Docs search page. https://ui.ballmac.com/templates/template-docs "use client" import * as React from "react" import { Search } from "lucide-react" import { searchIndex, type DocsKind } from "@/components/ballmac/templates/docs/docs-data" import { DocsShell, docsKindStyle, docsMonoClass, docsSerifClass, type DocsHrefs } from "@/components/ballmac/templates/docs/docs-theme" import { cn } from "@/lib/utils" const kinds = ["All", "Guide", "API", "Changelog"] as const const suggestions = ["retry", "idempotency", "dead letter", "visibility", "region"] function Marked({ text, query }: { text: string; query: string }) { const terms = query.trim().split(/\s+/).filter(Boolean).map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) if (terms.length === 0) return <>{text}</> const parts = text.split(new RegExp(`(${terms.join("|")})`, "ig")) return <>{parts.map((p, i) => (i % 2 === 1 ? <mark key={i} className="bg-chart-3/35 text-foreground rounded-sm px-0.5">{p}</mark> : <React.Fragment key={i}>{p}</React.Fragment>))}</> } type DocsSearchProps = React.ComponentProps<"div"> & { hrefs?: Partial<DocsHrefs>; initialQuery?: string } /** A full search page: filter by type, matches highlighted in titles and summaries, helpful suggestions when nothing matches. */ function DocsSearch({ hrefs, initialQuery = "message", ...props }: DocsSearchProps) { const [query, setQuery] = React.useState(initialQuery) const [kind, setKind] = React.useState<(typeof kinds)[number]>("All") const link = { guide: "/docs/guides", reference: "/docs/reference", changelog: "/docs/changelog", ...hrefs } const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean) const all = searchIndex.filter((e) => terms.every((t) => `${e.title} ${e.summary} ${e.path}`.toLowerCase().includes(t))) const results = all.filter((e) => kind === "All" || e.kind === kind) const count = (k: DocsKind) => all.filter((e) => e.kind === k).length return ( <DocsShell page="search" hrefs={hrefs} sidebar={false} {...props}> <main className="mx-auto max-w-3xl px-4 py-14 sm:px-6 sm:py-20"> <h1 className={cn("text-[clamp(2.4rem,6vw,4rem)] leading-none", docsSerifClass)}>Search</h1> <form role="search" onSubmit={(e) => e.preventDefault()} className="mt-8"> <label htmlFor="ds-q" className="sr-only">Search the docs</label> <div className="bg-card focus-within:ring-ring/50 relative rounded-xl border shadow-sm focus-within:ring-[3px]"> <Search className="text-muted-foreground pointer-events-none absolute top-1/2 start-4 size-5 -translate-y-1/2" aria-hidden="true" /> <input id="ds-q" type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search guides, endpoints and releases" className="h-14 w-full rounded-xl bg-transparent pe-4 ps-12 text-base outline-none" /> </div> </form> <div className="mt-5 flex flex-wrap gap-2" role="group" aria-label="Filter by type"> {kinds.map((k) => ( <button key={k} type="button" aria-pressed={kind === k} onClick={() => setKind(k)} className="hover:bg-accent focus-visible:ring-ring/50 aria-pressed:bg-primary aria-pressed:text-primary-foreground inline-flex h-9 items-center gap-2 rounded-full border px-4 text-sm font-semibold outline-none focus-visible:ring-[3px]"> {k === "API" ? "API reference" : k === "Guide" ? "Guides" : k === "Changelog" ? "Releases" : "Everything"} {k !== "All" && <span className={cn("text-xs opacity-100", docsMonoClass)}>{count(k)}</span>} </button> ))} </div> <p role="status" className="text-muted-foreground mt-6 text-sm">{results.length} {results.length === 1 ? "result" : "results"}{query.trim() ? <> for “{query.trim()}”</> : null}</p> {results.length > 0 ? ( <ul className="mt-2 divide-y"> {results.map((e) => ( <li key={e.title}> <a href={link[e.page]} className="hover:bg-accent focus-visible:ring-ring/50 -mx-3 block rounded-xl px-3 py-5 outline-none focus-visible:ring-[3px]"> <span className="flex items-center gap-2 text-xs"> <span className={cn("rounded-md px-2 py-0.5 font-bold", docsKindStyle[e.kind])}>{e.kind === "API" ? "API" : e.kind}</span> <span className={cn("text-muted-foreground truncate", e.kind === "API" && docsMonoClass)}>{e.path}</span> </span> <span className={cn("mt-2 block text-2xl", docsSerifClass)}><Marked text={e.title} query={query} /></span> <span className="text-muted-foreground mt-1 block text-pretty"><Marked text={e.summary} query={query} /></span> </a> </li> ))} </ul> ) : ( <div className="bg-surface mt-4 rounded-2xl border p-10 text-center"> <p className={cn("text-3xl", docsSerifClass)}>Nothing for “{query.trim()}”.</p> <p className="text-muted-foreground mt-2">Check the spelling, try fewer words or start from one of these.</p> <div className="mt-6 flex flex-wrap justify-center gap-2">{suggestions.map((s) => <button key={s} type="button" onClick={() => { setQuery(s); setKind("All") }} className="bg-card hover:bg-accent focus-visible:ring-ring/50 h-9 rounded-full border px-4 text-sm font-semibold outline-none focus-visible:ring-[3px]">{s}</button>)}</div> </div> )} </main> </DocsShell> ) } export { DocsSearch, type DocsSearchProps }components/ballmac/templates/docs/docs-changelog.tsx// Ballmac UI: Docs changelog page. https://ui.ballmac.com/templates/template-docs "use client" import * as React from "react" import { Rss } from "lucide-react" import { releases, type Release } from "@/components/ballmac/templates/docs/docs-data" import { DocsShell, docsMonoClass, docsSerifClass, type DocsHrefs } from "@/components/ballmac/templates/docs/docs-theme" import { cn } from "@/lib/utils" const tags = ["All", "Added", "Improved", "Fixed", "Breaking"] as const const tagStyle: Record<Release["tag"], string> = { Added: "bg-chart-4/15", Improved: "bg-chart-2/15", Fixed: "bg-chart-3/20", Breaking: "bg-destructive/15" } /** Renders `code` spans written with backticks. */ function Ticks({ text }: { text: string }) { return <>{text.split("`").map((part, i) => (i % 2 === 1 ? <code key={i} className={cn("bg-muted rounded px-1 py-0.5 text-[0.85em]", docsMonoClass)}>{part}</code> : <React.Fragment key={i}>{part}</React.Fragment>))}</> } const fmt = (d: string) => new Date(d + "T00:00:00Z").toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" }) type DocsChangelogProps = React.ComponentProps<"div"> & { hrefs?: Partial<DocsHrefs> } /** The changelog: a version rail, tag filters and each release with its upgrade notes. */ function DocsChangelog({ hrefs, ...props }: DocsChangelogProps) { const [tag, setTag] = React.useState<(typeof tags)[number]>("All") const list = releases.filter((r) => tag === "All" || r.tag === tag) return ( <DocsShell page="changelog" hrefs={hrefs} {...props}> <div className="grid grid-cols-[minmax(0,1fr)] gap-12 px-4 py-10 sm:px-8 xl:grid-cols-[minmax(0,1fr)_14rem]"> <main className="mx-auto w-full min-w-0 max-w-3xl"> <h1 className={cn("text-[clamp(2.4rem,5vw,3.8rem)] leading-[1.05]", docsSerifClass)}>Changelog</h1> <p className="text-muted-foreground mt-4 text-xl text-pretty">What shipped, what changed and what to do about it. Breaking changes always get a major version and a month of notice.</p> <div className="mt-6 flex flex-wrap items-center gap-2" role="group" aria-label="Filter releases"> {tags.map((t) => <button key={t} type="button" aria-pressed={tag === t} onClick={() => setTag(t)} className="hover:bg-accent focus-visible:ring-ring/50 aria-pressed:bg-primary aria-pressed:text-primary-foreground h-9 rounded-full border px-4 text-sm font-semibold outline-none focus-visible:ring-[3px]">{t}</button>)} <a href="/docs/changelog.xml" className="text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 ms-auto inline-flex items-center gap-1.5 rounded-md text-sm font-medium outline-none focus-visible:ring-[3px]"><Rss className="size-4" aria-hidden="true" />RSS</a> </div> <p role="status" className="text-muted-foreground mt-4 text-sm">{list.length} {list.length === 1 ? "release" : "releases"}</p> <ol className="mt-4"> {list.map((r, i) => ( <li key={r.version} id={`v${r.version}`} className="relative grid scroll-mt-24 grid-cols-[1.5rem_minmax(0,1fr)] gap-x-4"> <div className="flex flex-col items-center"><span aria-hidden="true" className={cn("mt-2 size-3 rounded-full border-2", r.tag === "Breaking" ? "border-destructive bg-destructive" : "border-primary bg-background")} />{i < list.length - 1 && <span aria-hidden="true" className="bg-border mt-1 w-px flex-1" />}</div> <article aria-labelledby={`r-${r.version}`} className="pb-12"> <div className="flex flex-wrap items-center gap-3 text-sm"> <span className={cn("bg-secondary rounded-md px-2 py-0.5 text-xs font-bold", docsMonoClass)}>{r.version}</span> <span className={cn("rounded-md px-2 py-0.5 text-xs font-bold", tagStyle[r.tag])}>{r.tag}</span> <time dateTime={r.date} className="text-muted-foreground">{fmt(r.date)}</time> </div> <h2 id={`r-${r.version}`} className={cn("mt-3 text-3xl", docsSerifClass)}>{r.title}</h2> <p className="mt-2 text-pretty"><Ticks text={r.body} /></p> <ul className="mt-4 grid gap-2">{r.points.map((p) => <li key={p} className="text-muted-foreground flex gap-3 text-sm"><span className="bg-border mt-2 size-1.5 shrink-0 rounded-full" aria-hidden="true" /><span className="text-pretty"><Ticks text={p} /></span></li>)}</ul> </article> </li> ))} </ol> {list.length === 0 && <p className="text-muted-foreground py-10 text-center">No releases with this tag yet.</p>} </main> <aside className="hidden xl:block" aria-label="Versions"><div className="sticky top-24"><h2 className="text-xs font-bold tracking-[0.12em] uppercase">Versions</h2><ul className="mt-3 grid gap-1 border-s text-sm">{releases.map((r) => <li key={r.version}><a href={`#v${r.version}`} className={cn("text-muted-foreground hover:text-foreground focus-visible:ring-ring/50 -ms-px block rounded-e-md border-s-2 border-transparent px-3 py-1 outline-none hover:border-current focus-visible:ring-[3px]", docsMonoClass)}>{r.version}</a></li>)}</ul></div></aside> </div> </DocsShell> ) } export { DocsChangelog, type DocsChangelogProps }app/docs/page.tsx// Ballmac UI: Docs template route. https://ui.ballmac.com/templates/template-docs import type { Metadata } from "next" import { DocsHome } from "@/components/ballmac/templates/docs/docs-home" export const metadata: Metadata = { title: "Tern docs: queues that never lose a message", description: "Guides, API reference and changelog for Tern.", } export default function Page() { return <DocsHome /> }app/docs/guides/page.tsx// Ballmac UI: Docs template route. https://ui.ballmac.com/templates/template-docs import type { Metadata } from "next" import { DocsGuide } from "@/components/ballmac/templates/docs/docs-guide" export const metadata: Metadata = { title: "Send your first message · Tern docs", description: "Create a queue, send a message and acknowledge it in about seven minutes.", } export default function Page() { return <DocsGuide /> }app/docs/reference/page.tsx// Ballmac UI: Docs template route. https://ui.ballmac.com/templates/template-docs import type { Metadata } from "next" import { DocsReference } from "@/components/ballmac/templates/docs/docs-reference" export const metadata: Metadata = { title: "API reference · Tern docs", description: "Every endpoint, parameter and error code.", } export default function Page() { return <DocsReference /> }app/docs/search/page.tsx// Ballmac UI: Docs template route. https://ui.ballmac.com/templates/template-docs import type { Metadata } from "next" import { DocsSearch } from "@/components/ballmac/templates/docs/docs-search" export const metadata: Metadata = { title: "Search · Tern docs", description: "Search guides, endpoints and releases.", } export default function Page() { return <DocsSearch /> }app/docs/changelog/page.tsx// Ballmac UI: Docs template route. https://ui.ballmac.com/templates/template-docs import type { Metadata } from "next" import { DocsChangelog } from "@/components/ballmac/templates/docs/docs-changelog" export const metadata: Metadata = { title: "Changelog · Tern docs", description: "What shipped, what changed and what to do about it.", } export default function Page() { return <DocsChangelog /> }Update the import paths to match your project setup.
The page is added as a route in your app, with every block it uses.
What's inside
- Pages
- Home/docs
- Guide/docs/guides
- Reference/docs/reference
- Search/docs/search
- Changelog/docs/changelog
- Fonts
- Newsreader, Instrument Sans, JetBrains Mono (Google Fonts, loaded with next/font)
- Files
- 13 files · 10 Ballmac blocks and components
Built with
Dependencies
- npm
- lucide-react
Use with AI
Installs a five-page documentation site with ⌘K search, synced language tabs, an on-this-page list, endpoint cards and a changelog. Change content in docs-data.ts and the palette in docsCss. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Tern: documentation (@ballmac/template-docs) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Developer docs, API references and product help centres
- Any site that needs search, a guide layout and a changelog
Not for
- A long-form editorial site (use the publication template)
- Marketing pages without reference content
Registry JSON: https://ui.ballmac.com/r/template-docs.json
Free to use in personal and commercial projects.