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:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user