From 7e46f6a6471d1d701e6262ad896f7f7e9f7a200f Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 12 Jun 2026 20:53:30 +0800 Subject: [PATCH] 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 --- package-lock.json | 1 + packages/website/package.json | 1 + .../src/components/alternative-page.tsx | 38 ++++ .../src/components/docs-breadcrumbs.tsx | 34 +++ .../website/src/components/docs-markdown.tsx | 16 +- packages/website/src/components/docs-nav.tsx | 152 ++++++++++++++ .../website/src/components/docs-outline.tsx | 109 ++++++++++ .../website/src/components/site-footer.tsx | 18 +- .../website/src/components/site-shell.tsx | 4 +- .../content}/alternatives/claude-desktop.md | 4 +- .../src/content}/alternatives/codex-app.md | 4 +- .../src/content}/alternatives/conductor.md | 4 +- .../src/content}/alternatives/happy-coder.md | 4 +- .../src/content}/alternatives/openchamber.md | 4 +- .../content}/alternatives/opencode-desktop.md | 4 +- .../src/content}/alternatives/superset.md | 4 +- .../website/src/data/alternative-pages.ts | 71 +++++++ packages/website/src/docs.ts | 197 +++++++++++++++++- packages/website/src/llms.ts | 13 +- packages/website/src/routeTree.gen.ts | 149 +++++++++++++ .../routes/alternatives/claude-desktop.tsx | 6 + .../src/routes/alternatives/codex-app.tsx | 6 + .../src/routes/alternatives/conductor.tsx | 6 + .../src/routes/alternatives/happy-coder.tsx | 6 + .../src/routes/alternatives/openchamber.tsx | 6 + .../routes/alternatives/opencode-desktop.tsx | 6 + .../src/routes/alternatives/superset.tsx | 4 + packages/website/src/routes/blog.tsx | 2 +- packages/website/src/routes/changelog.tsx | 2 +- packages/website/src/routes/cloud.tsx | 2 +- packages/website/src/routes/docs.tsx | 121 +++-------- packages/website/src/routes/download.tsx | 2 +- packages/website/src/routes/privacy.tsx | 2 +- packages/website/src/routes/sponsor.tsx | 2 +- packages/website/src/server-entry.ts | 6 + packages/website/vite.config.ts | 19 +- public-docs/best-practices.md | 56 ----- public-docs/claude-code.md | 5 +- public-docs/cli.md | 3 +- public-docs/configuration.md | 3 +- public-docs/custom-providers.md | 3 +- public-docs/index.md | 1 + public-docs/mcp.md | 3 +- public-docs/metadata-generation.md | 3 +- public-docs/providers.md | 3 +- public-docs/schedules.md | 3 +- public-docs/security.md | 3 +- public-docs/skills.md | 3 +- public-docs/supported-providers.md | 3 +- public-docs/updates.md | 3 +- public-docs/voice.md | 3 +- public-docs/why.md | 1 + public-docs/workspaces.md | 3 +- public-docs/worktrees.md | 3 +- 54 files changed, 934 insertions(+), 200 deletions(-) create mode 100644 packages/website/src/components/alternative-page.tsx create mode 100644 packages/website/src/components/docs-breadcrumbs.tsx create mode 100644 packages/website/src/components/docs-nav.tsx create mode 100644 packages/website/src/components/docs-outline.tsx rename {public-docs => packages/website/src/content}/alternatives/claude-desktop.md (97%) rename {public-docs => packages/website/src/content}/alternatives/codex-app.md (96%) rename {public-docs => packages/website/src/content}/alternatives/conductor.md (96%) rename {public-docs => packages/website/src/content}/alternatives/happy-coder.md (96%) rename {public-docs => packages/website/src/content}/alternatives/openchamber.md (96%) rename {public-docs => packages/website/src/content}/alternatives/opencode-desktop.md (97%) rename {public-docs => packages/website/src/content}/alternatives/superset.md (96%) create mode 100644 packages/website/src/data/alternative-pages.ts create mode 100644 packages/website/src/routes/alternatives/claude-desktop.tsx create mode 100644 packages/website/src/routes/alternatives/codex-app.tsx create mode 100644 packages/website/src/routes/alternatives/conductor.tsx create mode 100644 packages/website/src/routes/alternatives/happy-coder.tsx create mode 100644 packages/website/src/routes/alternatives/openchamber.tsx create mode 100644 packages/website/src/routes/alternatives/opencode-desktop.tsx create mode 100644 packages/website/src/routes/alternatives/superset.tsx delete mode 100644 public-docs/best-practices.md diff --git a/package-lock.json b/package-lock.json index 1c3bdbaa2..bac220a62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -39223,6 +39223,7 @@ "@tanstack/react-router": "^1.166.4", "@tanstack/react-start": "^1.166.4", "framer-motion": "^12.35.2", + "github-slugger": "^2.0.0", "lucide-react": "^1.7.0", "react": "^19.1.4", "react-dom": "^19.1.4", diff --git a/packages/website/package.json b/packages/website/package.json index 64d1141cd..df8023e00 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -16,6 +16,7 @@ "@tanstack/react-router": "^1.166.4", "@tanstack/react-start": "^1.166.4", "framer-motion": "^12.35.2", + "github-slugger": "^2.0.0", "lucide-react": "^1.7.0", "react": "^19.1.4", "react-dom": "^19.1.4", diff --git a/packages/website/src/components/alternative-page.tsx b/packages/website/src/components/alternative-page.tsx new file mode 100644 index 000000000..ec583fefa --- /dev/null +++ b/packages/website/src/components/alternative-page.tsx @@ -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 ; + }, + }; +} + +function AlternativePageContent({ slug }: { slug: string }) { + const page = getAlternativePage(slug); + + if (!page) { + return ( + +

Page not found.

+
+ ); + } + + return ( + + {page.content} + + ); +} diff --git a/packages/website/src/components/docs-breadcrumbs.tsx b/packages/website/src/components/docs-breadcrumbs.tsx new file mode 100644 index 000000000..7b82e7b4f --- /dev/null +++ b/packages/website/src/components/docs-breadcrumbs.tsx @@ -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 ( + + ); +} diff --git a/packages/website/src/components/docs-markdown.tsx b/packages/website/src/components/docs-markdown.tsx index f0ca4cd3d..98bdf4a93 100644 --- a/packages/website/src/components/docs-markdown.tsx +++ b/packages/website/src/components/docs-markdown.tsx @@ -70,12 +70,14 @@ const docsMarkdownComponents: Components = { export function DocsMarkdown({ children }: { children: string }) { return ( - - {children} - +
+ + {children} + +
); } diff --git a/packages/website/src/components/docs-nav.tsx b/packages/website/src/components/docs-nav.tsx new file mode 100644 index 000000000..f9188213c --- /dev/null +++ b/packages/website/src/components/docs-nav.tsx @@ -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; + mobile?: boolean; + onNavigate?: () => void; +}) { + const location = useLocation(); + const isActive = location.pathname === node.href; + + return ( + + {node.label} + + ); +} + +function GroupNode({ + node, + mobile, + onNavigate, +}: { + node: Extract; + 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 ( +
+ + {isOpen && ( +
+ +
+ )} +
+ ); +} + +function CategoryNode({ + node, + mobile, + onNavigate, +}: { + node: Extract; + mobile?: boolean; + onNavigate?: () => void; +}) { + return ( +
+
{node.label}
+ +
+ ); +} + +function NavTree({ nodes, mobile, onNavigate }: DocsNavProps) { + return ( +
+ {nodes.map((node) => { + if (node.type === "category") { + return ( + + ); + } + if (node.type === "group") { + return ( + + ); + } + return ( + + ); + })} +
+ ); +} + +export function DocsNav({ nodes, mobile, onNavigate }: DocsNavProps) { + return ( +
+ +
+ ); +} diff --git a/packages/website/src/components/docs-outline.tsx b/packages/website/src/components/docs-outline.tsx new file mode 100644 index 000000000..c9ab1fc8e --- /dev/null +++ b/packages/website/src/components/docs-outline.tsx @@ -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) => { + 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 ( + = 4 && "pl-9", + active + ? "text-foreground border-primary" + : "text-muted-foreground hover:text-foreground border-transparent", + )} + > + {heading.text} + + ); +} + +export function DocsOutline({ headings }: DocsOutlineProps) { + const [activeId, setActiveId] = useState(() => + 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 ( + + ); +} diff --git a/packages/website/src/components/site-footer.tsx b/packages/website/src/components/site-footer.tsx index efc25c14a..8f61637dd 100644 --- a/packages/website/src/components/site-footer.tsx +++ b/packages/website/src/components/site-footer.tsx @@ -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 (