Block
Docs Layout.
A documentation page shell: header with search, grouped sidebar nav, a Prose article and an On this page list. On phones the nav moves into a sheet.
components/docs-layout/docs-layout.tsx
"use client";
import * as React from "react";
import { ScrollArea } from "@/components/ui/scroll-area";
import { articleToc, DocsArticle } from "./docs-article";
import { DocsHeader } from "./docs-header";
import { DocsNav } from "./docs-nav";
import { Toc } from "./toc";
export default function DocsLayout() {
const [active, setActive] = React.useState("#publishing");
return (
<div className="min-h-svh bg-background">
<DocsHeader active={active} onNavigate={setActive} />
<div className="mx-auto flex max-w-[1400px] gap-10 px-4 sm:px-6 lg:px-10">
<aside className="sticky top-16 hidden h-[calc(100svh-4rem)] w-56 shrink-0 border-r lg:block">
<ScrollArea className="h-full">
<DocsNav active={active} onNavigate={setActive} className="py-8 pr-4" />
</ScrollArea>
</aside>
<main className="min-w-0 flex-1 py-10 lg:py-12">
<DocsArticle />
</main>
<aside className="sticky top-16 hidden h-[calc(100svh-4rem)] w-52 shrink-0 py-12 xl:block">
<Toc items={articleToc} />
</aside>
</div>
</div>
);
}components/docs-layout/docs-header.tsx
"use client";
import * as React from "react";
import { MenuIcon, Search } from "lucide-react";
import { Button } from "@/components/ui/button";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Kbd } from "@/components/ui/kbd";
import { NavbarLabel, NavbarMark } from "@/components/ui/navbar";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "@/components/ui/sheet";
import { DocsNav } from "./docs-nav";
const links = [
{ title: "Docs", href: "#introduction", active: true },
{ title: "Components", href: "#components" },
{ title: "Changelog", href: "#changelog" },
];
function DocsSearch({ className, hint = true }: { className?: string; hint?: boolean }) {
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === "k" && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
inputRef.current?.focus();
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
return (
<InputGroup className={className}>
<InputGroupAddon>
<Search />
</InputGroupAddon>
<InputGroupInput ref={inputRef} type="search" placeholder="Search the docs" aria-label="Search the docs" />
{hint && (
<InputGroupAddon align="inline-end">
<Kbd>Ctrl K</Kbd>
</InputGroupAddon>
)}
</InputGroup>
);
}
/** Sticky docs header. Below lg the sidebar moves into a Sheet opened from the menu button. */
export function DocsHeader({ active, onNavigate }: { active?: string; onNavigate?: (href: string) => void }) {
const [open, setOpen] = React.useState(false);
return (
<header className="sticky top-0 z-40 border-b bg-background/85 backdrop-blur-md">
<div className="mx-auto flex h-16 max-w-[1400px] items-center gap-3 px-4 sm:gap-4 sm:px-6 lg:px-10">
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="icon-sm" aria-label="Open navigation" className="lg:hidden">
<MenuIcon />
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-[85%] max-w-xs">
<SheetHeader className="border-b">
<SheetTitle className="flex items-center gap-2 font-display text-xl font-semibold tracking-[-0.04em]">
<NavbarMark className="size-6" /> lantern
</SheetTitle>
<SheetDescription className="sr-only">Documentation navigation</SheetDescription>
</SheetHeader>
<div className="grid gap-6 overflow-y-auto px-3 pb-8">
<DocsSearch hint={false} className="sm:hidden" />
<DocsNav
active={active}
onNavigate={(href) => {
onNavigate?.(href);
setOpen(false);
}}
/>
</div>
</SheetContent>
</Sheet>
<a
href="#"
className="flex min-w-0 items-center gap-2 rounded-sm font-display text-xl font-semibold tracking-[-0.04em] outline-none focus-visible:ring-2 focus-visible:ring-ring sm:text-2xl"
>
<NavbarMark className="size-6 sm:size-7" />
lantern
<NavbarLabel>Docs</NavbarLabel>
</a>
<nav aria-label="Main" className="ml-6 hidden items-center gap-6 text-[13px] lg:flex">
{links.map((link) => (
<a
key={link.title}
href={link.href}
aria-current={link.active ? "page" : undefined}
className="rounded-sm transition-colors outline-none hover:text-primary focus-visible:ring-2 focus-visible:ring-ring aria-[current=page]:text-primary"
>
{link.title}
</a>
))}
</nav>
<DocsSearch className="ml-auto hidden w-full max-w-64 sm:flex" />
<Button
variant="ghost"
size="icon-sm"
aria-label="Search the docs"
className="ml-auto sm:hidden"
onClick={() => setOpen(true)}
>
<Search />
</Button>
</div>
</header>
);
}components/docs-layout/docs-nav.tsx
"use client";
import { cn } from "@/lib/utils";
export type DocsNavGroup = { title: string; items: { title: string; href: string }[] };
export const docsNavGroups: DocsNavGroup[] = [
{
title: "Getting started",
items: [
{ title: "Introduction", href: "#introduction" },
{ title: "Installation", href: "#installation" },
{ title: "Your first hub", href: "#first-hub" },
],
},
{
title: "Guides",
items: [
{ title: "Publishing a site", href: "#publishing" },
{ title: "Site keys", href: "#site-keys" },
{ title: "Guestbooks", href: "#guestbooks" },
{ title: "Turtle relays", href: "#relays" },
],
},
{
title: "Reference",
items: [
{ title: "lantern CLI", href: "#cli" },
{ title: "Lua API", href: "#lua-api" },
{ title: "Page components", href: "#components" },
{ title: "Status codes", href: "#status-codes" },
],
},
];
/** Grouped docs links. The active item gets the orange left border. */
export function DocsNav({
groups = docsNavGroups,
active,
onNavigate,
className,
}: {
groups?: DocsNavGroup[];
active?: string;
onNavigate?: (href: string) => void;
className?: string;
}) {
return (
<nav aria-label="Documentation" className={cn("grid gap-7", className)}>
{groups.map((group) => (
<div key={group.title}>
<div className="mb-2 px-3 font-mono text-[10px] font-semibold tracking-[0.2em] text-success uppercase">
{group.title}
</div>
<ul className="grid gap-px">
{group.items.map((item) => {
const isActive = item.href === active;
return (
<li key={item.href}>
<a
href={item.href}
onClick={() => onNavigate?.(item.href)}
aria-current={isActive ? "page" : undefined}
className={cn(
"block rounded-r-md border-l px-3 py-1.5 text-[13.5px] transition-colors outline-none focus-visible:bg-secondary",
isActive
? "border-primary bg-card text-foreground"
: "border-transparent text-muted-foreground hover:border-input hover:text-foreground",
)}
>
{item.title}
</a>
</li>
);
})}
</ul>
</div>
))}
</nav>
);
}components/docs-layout/docs-article.tsx
import { ArrowLeft, ArrowRight, Info } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { CodeBlock } from "@/components/ui/code-block";
import { Eyebrow } from "@/components/ui/eyebrow";
import { Prose } from "@/components/ui/typography";
import type { TocItem } from "./toc";
export const articleToc: TocItem[] = [
{ id: "before-you-start", title: "Before you start" },
{ id: "write-a-page", title: "Write a page" },
{ id: "publish", title: "Publish it" },
{ id: "publish-flags", title: "Useful flags", depth: 3 },
{ id: "next-steps", title: "Next steps" },
];
const startup = `-- site/index.lua: the first page visitors see
local page = lantern.page("Welcome home")
page:heading("A place worth finding.")
page:text("A field guide. A build journal.")
page:guestbook({ max = 50 })
return page`;
/** A sample docs article. Swap it for your own MDX or CMS content. */
export function DocsArticle() {
return (
<article className="min-w-0">
<Breadcrumb className="mb-6">
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink href="#">Docs</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbLink href="#">Guides</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>Publishing a site</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<Eyebrow>Guide</Eyebrow>
<Prose className="mt-3 max-w-none">
<h1>
Publishing a site<span className="text-primary">.</span>
</h1>
<p className="text-lg text-muted-foreground">
Take a folder of pages from an in-game computer and put it on the network, where anyone on any server can
visit.
</p>
<h2 id="before-you-start">Before you start</h2>
<p>
You need an advanced computer with a wired or wireless modem, and HTTP enabled in the server config. Run{" "}
<code>lantern version</code> to check the program is installed.
</p>
<ul>
<li>
A site key from your <a href="#dashboard">dashboard</a>.
</li>
<li>A folder with at least an <code>index.lua</code> page.</li>
<li>About two minutes.</li>
</ul>
<h2 id="write-a-page">Write a page</h2>
<p>
Pages are small Lua files that return a page object. Start with a heading, a line of text and a guestbook so
visitors can say hello.
</p>
<div className="not-prose my-6">
<CodeBlock title="site/index.lua" language="lua" code={startup} showLineNumbers highlightLines={[6]} />
</div>
<h2 id="publish">Publish it</h2>
<p>
From the folder above <code>site</code>, run the publish command with your key. Lantern uploads every page,
checks it, and prints the address when it is live.
</p>
<div className="not-prose my-6">
<CodeBlock code={"lantern publish ./site --key $SITE_KEY"} />
</div>
<div className="not-prose my-6">
<Alert>
<Info />
<AlertTitle>Keep your key out of your pages</AlertTitle>
<AlertDescription>
<p>Anyone with the key can overwrite your site. Store it in a settings file, not in index.lua.</p>
</AlertDescription>
</Alert>
</div>
<h3 id="publish-flags">Useful flags</h3>
<ol>
<li>
<code>--draft</code> uploads without making the site public.
</li>
<li>
<code>--watch</code> republishes whenever a file changes.
</li>
<li>
<code>--quiet</code> prints only the final address.
</li>
</ol>
<h2 id="next-steps">Next steps</h2>
<p>
Your site is live. Add a <a href="#guestbooks">guestbook</a>, set up a{" "}
<a href="#relays">turtle relay</a> for servers without HTTP, or read the <a href="#lua-api">Lua API</a> to
build something stranger.
</p>
</Prose>
<div className="mt-14 grid gap-3 border-t pt-8 sm:grid-cols-2">
<a
href="#first-hub"
className="group rounded-lg border bg-card p-4 transition-colors outline-none hover:border-input focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="flex items-center gap-1.5 font-mono text-[10px] tracking-[0.2em] text-muted-foreground uppercase">
<ArrowLeft className="size-3.5 text-primary" /> Previous
</span>
<span className="mt-1.5 block font-display text-lg tracking-tight group-hover:text-primary">
Your first hub
</span>
</a>
<a
href="#site-keys"
className="group rounded-lg border bg-card p-4 text-right transition-colors outline-none hover:border-input focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="flex items-center justify-end gap-1.5 font-mono text-[10px] tracking-[0.2em] text-muted-foreground uppercase">
Next <ArrowRight className="size-3.5 text-primary" />
</span>
<span className="mt-1.5 block font-display text-lg tracking-tight group-hover:text-primary">Site keys</span>
</a>
</div>
</article>
);
}components/docs-layout/toc.tsx
"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export type TocItem = { id: string; title: string; depth?: 2 | 3 };
/** "On this page" links. Highlights the heading nearest the top of the viewport. */
export function Toc({ items, className }: { items: TocItem[]; className?: string }) {
const [current, setCurrent] = React.useState(items[0]?.id);
const ids = items.map((item) => item.id).join(",");
React.useEffect(() => {
const headings = ids
.split(",")
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null);
if (headings.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries.filter((e) => e.isIntersecting).sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible[0]) setCurrent(visible[0].target.id);
},
{ rootMargin: "-72px 0px -65% 0px" },
);
headings.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [ids]);
return (
<nav aria-label="On this page" className={cn("text-[13px]", className)}>
<div className="mb-3 font-mono text-[10px] font-semibold tracking-[0.2em] text-success uppercase">On this page</div>
<ul className="grid gap-2 border-l">
{items.map((item) => (
<li key={item.id}>
<a
href={`#${item.id}`}
aria-current={current === item.id ? "location" : undefined}
className={cn(
"-ml-px block border-l py-0.5 transition-colors outline-none focus-visible:text-foreground",
item.depth === 3 ? "pl-6" : "pl-3",
current === item.id
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{item.title}
</a>
</li>
))}
</ul>
</nav>
);
}Installation#
$npx lanterncn add docs-layoutThis copies all 5 files into components/docs-layout and installs every component it uses. With the shadcn CLI: npx shadcn@latest add httptim/lantern-ui/docs-layout.
Usage#
Render it from any page.
import DocsLayout from "@/components/docs-layout/docs-layout";
export default function Page() {
return <DocsLayout />;
}