Rework docs navigation and move alternatives to top-level pages (#1487)

* Rework docs navigation and move alternatives to top-level pages

- Add nested docs nav with categories, collapsible groups, breadcrumbs,
  and a right-hand page outline with heading slugs.
- Move alternatives out of /docs/alternatives into /alternatives/*
  marketing pages with 301 redirects.
- Add github-slugger for heading IDs and require explicit width on
  SiteShell so new pages can't accidentally pick the narrow prose layout.
- Remove the best-practices doc from public-docs.

* Make docs content column prose-width
This commit is contained in:
Mohamed Boudra
2026-06-12 12:53:30 +00:00
committed by GitHub
parent fb0c5b27f3
commit 7e46f6a647
54 changed files with 934 additions and 200 deletions
@@ -0,0 +1,38 @@
import { DocsMarkdown } from "~/components/docs-markdown";
import { SiteShell } from "~/components/site-shell";
import { getAlternativePage } from "~/data/alternative-pages";
import { pageMeta } from "~/meta";
export function alternativeRouteOptions(slug: string) {
const page = getAlternativePage(slug);
return {
head: () =>
pageMeta(
page?.title ?? "Alternative - Paseo",
page?.description ?? "",
`/alternatives/${slug}`,
),
component: function AlternativePageRoute() {
return <AlternativePageContent slug={slug} />;
},
};
}
function AlternativePageContent({ slug }: { slug: string }) {
const page = getAlternativePage(slug);
if (!page) {
return (
<SiteShell width="default">
<p className="text-muted-foreground">Page not found.</p>
</SiteShell>
);
}
return (
<SiteShell width="default">
<DocsMarkdown>{page.content}</DocsMarkdown>
</SiteShell>
);
}
@@ -0,0 +1,34 @@
import { Link } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
import { type Doc, type DocsNavNode, getDocBreadcrumbGroups } from "~/docs";
interface DocsBreadcrumbsProps {
doc: Doc;
tree: DocsNavNode[];
}
export function DocsBreadcrumbs({ doc, tree }: DocsBreadcrumbsProps) {
const groups = getDocBreadcrumbGroups(doc, tree);
return (
<nav aria-label="Breadcrumb" className="not-prose mb-6">
<ol className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<li>
<Link to="/docs" className="hover:text-foreground transition-colors">
Docs
</Link>
</li>
{groups.map((group) => (
<li key={group.label} className="flex items-center gap-2">
<ChevronRight size={14} className="text-border" />
<span>{group.label}</span>
</li>
))}
<li className="flex items-center gap-2">
<ChevronRight size={14} className="text-border" />
<span className="text-foreground">{doc.frontmatter.nav}</span>
</li>
</ol>
</nav>
);
}
@@ -70,12 +70,14 @@ const docsMarkdownComponents: Components = {
export function DocsMarkdown({ children }: { children: string }) {
return (
<ReactMarkdown
remarkPlugins={docsRemarkPlugins}
rehypePlugins={docsRehypePlugins}
components={docsMarkdownComponents}
>
{children}
</ReactMarkdown>
<div className="docs-prose">
<ReactMarkdown
remarkPlugins={docsRemarkPlugins}
rehypePlugins={docsRehypePlugins}
components={docsMarkdownComponents}
>
{children}
</ReactMarkdown>
</div>
);
}
@@ -0,0 +1,152 @@
import { Link, useLocation } from "@tanstack/react-router";
import { ChevronDown, ChevronRight } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { type DocsNavNode } from "~/docs";
interface DocsNavProps {
nodes: DocsNavNode[];
mobile?: boolean;
onNavigate?: () => void;
}
const ACTIVE_OPTIONS_EXACT = { exact: true };
function nodeContainsHref(node: DocsNavNode, href: string): boolean {
if (node.type === "page") return node.href === href;
return node.children.some((child) => nodeContainsHref(child, href));
}
function clsx(...classes: (string | false | null | undefined)[]): string {
return classes.filter(Boolean).join(" ");
}
function PageLink({
node,
mobile,
onNavigate,
}: {
node: Extract<DocsNavNode, { type: "page" }>;
mobile?: boolean;
onNavigate?: () => void;
}) {
const location = useLocation();
const isActive = location.pathname === node.href;
return (
<Link
to={node.href}
activeOptions={ACTIVE_OPTIONS_EXACT}
onClick={onNavigate}
className={clsx(
"block px-3 py-2 text-sm rounded-md transition-colors",
mobile
? "text-muted-foreground hover:text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
isActive && (mobile ? "text-foreground" : "bg-muted text-foreground"),
)}
>
{node.label}
</Link>
);
}
function GroupNode({
node,
mobile,
onNavigate,
}: {
node: Extract<DocsNavNode, { type: "group" }>;
mobile?: boolean;
onNavigate?: () => void;
}) {
const location = useLocation();
const currentHref = location.pathname;
const containsActive = useMemo(() => nodeContainsHref(node, currentHref), [node, currentHref]);
const [isOpen, setIsOpen] = useState(containsActive);
const toggle = useCallback(() => setIsOpen((open) => !open), []);
useEffect(() => {
setIsOpen(containsActive);
}, [containsActive]);
return (
<div>
<button
type="button"
onClick={toggle}
className={clsx(
"w-full flex items-center justify-between gap-2 px-3 py-2 text-sm rounded-md transition-colors",
mobile
? "text-muted-foreground hover:text-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
containsActive && "text-foreground",
)}
>
<span>{node.label}</span>
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
{isOpen && (
<div className="ml-3 pl-3 border-l border-border space-y-0.5">
<NavTree nodes={node.children} mobile={mobile} onNavigate={onNavigate} />
</div>
)}
</div>
);
}
function CategoryNode({
node,
mobile,
onNavigate,
}: {
node: Extract<DocsNavNode, { type: "category" }>;
mobile?: boolean;
onNavigate?: () => void;
}) {
return (
<div className={mobile ? "space-y-1" : "space-y-1 mt-6 first:mt-0"}>
<div className="px-3 py-2 text-xs font-medium text-foreground">{node.label}</div>
<NavTree nodes={node.children} mobile={mobile} onNavigate={onNavigate} />
</div>
);
}
function NavTree({ nodes, mobile, onNavigate }: DocsNavProps) {
return (
<div className="space-y-0.5">
{nodes.map((node) => {
if (node.type === "category") {
return (
<CategoryNode
key={`category-${node.label}`}
node={node}
mobile={mobile}
onNavigate={onNavigate}
/>
);
}
if (node.type === "group") {
return (
<GroupNode
key={`group-${node.segment}`}
node={node}
mobile={mobile}
onNavigate={onNavigate}
/>
);
}
return (
<PageLink key={`page-${node.href}`} node={node} mobile={mobile} onNavigate={onNavigate} />
);
})}
</div>
);
}
export function DocsNav({ nodes, mobile, onNavigate }: DocsNavProps) {
return (
<div className={mobile ? undefined : "-ml-3"}>
<NavTree nodes={nodes} mobile={mobile} onNavigate={onNavigate} />
</div>
);
}
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useState } from "react";
import { type DocHeading } from "~/docs";
interface DocsOutlineProps {
headings: DocHeading[];
}
function clsx(...classes: (string | false | null | undefined)[]): string {
return classes.filter(Boolean).join(" ");
}
function OutlineLink({
heading,
active,
onActivate,
}: {
heading: DocHeading;
active: boolean;
onActivate: (id: string) => void;
}) {
const handleClick = useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
const element = document.getElementById(heading.id);
if (element) {
element.scrollIntoView({ behavior: "smooth" });
window.history.replaceState(null, "", `#${heading.id}`);
onActivate(heading.id);
}
},
[heading.id, onActivate],
);
return (
<a
href={`#${heading.id}`}
onClick={handleClick}
className={clsx(
"block py-1 transition-colors border-l-2 -ml-px",
heading.depth === 2 && "pl-3",
heading.depth === 3 && "pl-6",
heading.depth >= 4 && "pl-9",
active
? "text-foreground border-primary"
: "text-muted-foreground hover:text-foreground border-transparent",
)}
>
{heading.text}
</a>
);
}
export function DocsOutline({ headings }: DocsOutlineProps) {
const [activeId, setActiveId] = useState<string | null>(() =>
headings.length > 0 ? headings[0].id : null,
);
useEffect(() => {
setActiveId(headings.length > 0 ? headings[0].id : null);
}, [headings]);
useEffect(() => {
if (headings.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.map((entry) => entry.target.id);
if (visible.length > 0) {
setActiveId(visible[0]);
}
},
{
rootMargin: "-10% 0px -70% 0px",
threshold: 0,
},
);
for (const heading of headings) {
const element = document.getElementById(heading.id);
if (element) observer.observe(element);
}
return () => observer.disconnect();
}, [headings]);
if (headings.length === 0) return null;
return (
<nav
aria-label="On this page"
className="sticky top-8 max-h-[calc(100vh-4rem)] overflow-y-auto py-8 pr-4"
>
<div className="text-xs font-medium text-muted-foreground mb-3 px-3">On this page</div>
<ul className="space-y-1 border-l border-border">
{headings.map((heading) => (
<li key={heading.id} className="text-sm">
<OutlineLink
heading={heading}
active={activeId === heading.id}
onActivate={setActiveId}
/>
</li>
))}
</ul>
</nav>
);
}
@@ -1,3 +1,4 @@
import { getAlternativePages } from "~/data/alternative-pages";
import { appStoreUrl, playStoreUrl, webAppUrl } from "~/downloads";
interface SiteFooterProps {
@@ -7,9 +8,10 @@ interface SiteFooterProps {
export function SiteFooter({ width = "default" }: SiteFooterProps) {
const widthClasses =
width === "prose" ? "max-w-prose p-6 md:p-12 md:pt-0" : "max-w-5xl p-6 md:p-20 md:pt-0";
const alternatives = getAlternativePages();
return (
<footer className={`${widthClasses} mx-auto`}>
<div className="border-t border-white/10 pt-8 pb-4 grid grid-cols-2 sm:grid-cols-4 gap-8 text-sm">
<div className="border-t border-white/10 pt-8 pb-4 grid grid-cols-2 sm:grid-cols-5 gap-8 text-sm">
<div className="space-y-3">
<p className="text-white/60 font-medium">Product</p>
<div className="space-y-2">
@@ -80,6 +82,20 @@ export function SiteFooter({ width = "default" }: SiteFooterProps) {
</a>
</div>
</div>
<div className="space-y-3">
<p className="text-white/60 font-medium">Alternatives</p>
<div className="space-y-2">
{alternatives.map((page) => (
<a
key={page.slug}
href={page.href}
className="block text-muted-foreground hover:text-foreground transition-colors"
>
{page.name}
</a>
))}
</div>
</div>
<div className="space-y-3">
<p className="text-white/60 font-medium">Community</p>
<div className="space-y-2">
@@ -4,10 +4,10 @@ import { SiteHeader } from "~/components/site-header";
interface SiteShellProps {
children: ReactNode;
width?: "default" | "prose";
width: "default" | "prose";
}
export function SiteShell({ children, width = "default" }: SiteShellProps) {
export function SiteShell({ children, width }: SiteShellProps) {
const mainClasses =
width === "prose" ? "max-w-prose p-6 md:p-12 mx-auto" : "max-w-5xl p-6 md:p-20 mx-auto";
return (