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 20:53:30 +08:00
committed by GitHub
parent fb0c5b27f3
commit 7e46f6a647
54 changed files with 934 additions and 200 deletions

1
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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">

View File

@@ -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 (

View File

@@ -2,7 +2,7 @@
title: Open Source Claude Desktop Alternative With Linux, Mobile, and Multi-Provider Support
description: Paseo is an open source Claude Desktop alternative for developers who want Linux, self-hosting, native mobile apps, and Claude Code alongside Codex, OpenCode, Copilot, and more.
nav: Claude Desktop
order: 106
order: 55
---
# Paseo vs Claude Desktop
@@ -115,4 +115,4 @@ Claude supports voice in Claude's own mobile and app surfaces. Claude Code itsel
| MCP server for orchestration | Yes | MCP support inside Claude Code |
| Self-hosted daemon | Yes | No |
See also: [Paseo vs Codex App](/docs/alternatives/codex-app), [Paseo vs OpenCode Desktop](/docs/alternatives/opencode-desktop), [Paseo vs Conductor](/docs/alternatives/conductor).
See also: [Paseo vs Codex App](/alternatives/codex-app), [Paseo vs OpenCode Desktop](/alternatives/opencode-desktop), [Paseo vs Conductor](/alternatives/conductor).

View File

@@ -2,7 +2,7 @@
title: Open Source Codex App Alternative With Linux, Mobile, and Multi-Provider Support
description: Paseo is an open source alternative to Codex App for developers who want Linux, native mobile apps, a self-hosted daemon, and Codex alongside Claude Code, OpenCode, Copilot, and more.
nav: Codex App
order: 105
order: 54
---
# Paseo vs Codex App
@@ -107,4 +107,4 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe
| Voice | Dictation and realtime voice | Dictation |
| Self-hosted daemon | Yes | No |
See also: [Paseo vs Claude Desktop](/docs/alternatives/claude-desktop), [Paseo vs OpenCode Desktop](/docs/alternatives/opencode-desktop), [Supported providers](/docs/supported-providers).
See also: [Paseo vs Claude Desktop](/alternatives/claude-desktop), [Paseo vs OpenCode Desktop](/alternatives/opencode-desktop), [Supported providers](/docs/supported-providers).

View File

@@ -2,7 +2,7 @@
title: Open Source Conductor Alternative With Linux, Windows, and Mobile
description: Paseo is open source, runs on macOS, Linux, and Windows, ships native iOS and Android apps, and supports 30+ agents through the in-app catalog plus any ACP or CLI agent. Conductor is macOS only and Claude Code or Codex only.
nav: Conductor
order: 100
order: 50
---
# Paseo vs Conductor
@@ -94,4 +94,4 @@ Paseo's speech-to-text and text-to-speech run locally on your device. Nothing le
| Local voice (on-device) | Yes | — |
| Self-hosted daemon | Yes | — |
See also: [Paseo vs Superset](/docs/alternatives/superset), [Paseo vs OpenChamber](/docs/alternatives/openchamber), [Paseo vs Happy Coder](/docs/alternatives/happy-coder).
See also: [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber), [Paseo vs Happy Coder](/alternatives/happy-coder).

View File

@@ -2,7 +2,7 @@
title: Happy Coder Alternative With a Desktop App and Git Worktrees
description: Paseo ships a native desktop app, runs agents in isolated git worktrees, and supports 30+ agents. Happy Coder is mobile and web only, wraps the agent CLI, and supports Claude Code and Codex.
nav: Happy Coder
order: 104
order: 53
---
# Paseo vs Happy Coder
@@ -96,4 +96,4 @@ Paseo's speech-to-text and text-to-speech run locally on your device. Nothing le
| CLI | Run, `--host`, ls, send, schedule, loop | Launch wrapped session |
| Local voice (on-device) | Yes | — |
See also: [Paseo vs Conductor](/docs/alternatives/conductor), [Paseo vs Superset](/docs/alternatives/superset), [Paseo vs OpenChamber](/docs/alternatives/openchamber).
See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs OpenChamber](/alternatives/openchamber).

View File

@@ -2,7 +2,7 @@
title: OpenChamber Alternative With Linux, Windows, and Mobile
description: Paseo ships native iOS and Android apps, runs on macOS, Linux, and Windows, and supports 30+ agents. OpenChamber is macOS only with a PWA and is built around OpenCode.
nav: OpenChamber
order: 103
order: 52
---
# Paseo vs OpenChamber
@@ -90,4 +90,4 @@ Paseo's speech-to-text and text-to-speech run locally on your device. OpenChambe
| Local voice (on-device) | Yes | — |
| Self-hosted daemon | Yes | — |
See also: [Paseo vs Conductor](/docs/alternatives/conductor), [Paseo vs Superset](/docs/alternatives/superset), [Paseo vs Happy Coder](/docs/alternatives/happy-coder).
See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs Superset](/alternatives/superset), [Paseo vs Happy Coder](/alternatives/happy-coder).

View File

@@ -2,7 +2,7 @@
title: OpenCode Desktop Alternative With Native Mobile and Multi-Provider Orchestration
description: Paseo is an OpenCode Desktop alternative for developers who want native mobile apps, a self-hosted daemon, and OpenCode alongside Claude Code, Codex, Copilot, and more.
nav: OpenCode Desktop
order: 107
order: 56
---
# Paseo vs OpenCode Desktop
@@ -113,4 +113,4 @@ Paseo supports dictation and realtime voice mode. Speech-to-text and text-to-spe
| Local voice | Yes | No |
| Self-hosted daemon | Yes | OpenCode server / local runtime |
See also: [Paseo vs Codex App](/docs/alternatives/codex-app), [Paseo vs Claude Desktop](/docs/alternatives/claude-desktop), [Paseo vs OpenChamber](/docs/alternatives/openchamber).
See also: [Paseo vs Codex App](/alternatives/codex-app), [Paseo vs Claude Desktop](/alternatives/claude-desktop), [Paseo vs OpenChamber](/alternatives/openchamber).

View File

@@ -2,7 +2,7 @@
title: Superset Alternative With Linux, Windows, and Mobile
description: Paseo is open source under an OSI license, has no login wall, ships native mobile, and runs on macOS, Linux, and Windows. Superset is source-available, macOS only, and gates the desktop app on a Superset login.
nav: Superset
order: 101
order: 51
---
# Paseo vs Superset
@@ -113,4 +113,4 @@ Superset is free for one seat with local workspaces only. Team features and sync
| Local voice (on-device) | Yes | — |
| Self-hosted daemon | Yes | — |
See also: [Paseo vs Conductor](/docs/alternatives/conductor), [Paseo vs OpenChamber](/docs/alternatives/openchamber), [Paseo vs Happy Coder](/docs/alternatives/happy-coder).
See also: [Paseo vs Conductor](/alternatives/conductor), [Paseo vs OpenChamber](/alternatives/openchamber), [Paseo vs Happy Coder](/alternatives/happy-coder).

View File

@@ -0,0 +1,71 @@
export interface AlternativePage {
slug: string;
name: string;
href: string;
title: string;
description: string;
content: string;
order: number;
}
function parseFrontmatter(raw: string): { data: Record<string, string>; content: string } {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) return { data: {}, content: raw };
const data: Record<string, string> = {};
for (const line of match[1].split("\n")) {
const colonIdx = line.indexOf(":");
if (colonIdx === -1) continue;
const key = line.slice(0, colonIdx).trim();
const value = line
.slice(colonIdx + 1)
.trim()
.replace(/^["']|["']$/g, "");
data[key] = value;
}
return { data, content: match[2] };
}
const alternativeModules = import.meta.glob("../content/alternatives/*.md", {
eager: true,
query: "?raw",
import: "default",
}) as Record<string, string>;
function loadAlternativePages(): AlternativePage[] {
const pages: AlternativePage[] = [];
for (const [path, raw] of Object.entries(alternativeModules)) {
const fileName = path.split("/").pop() ?? "";
const slug = fileName.replace(/\.md$/, "");
const { data, content } = parseFrontmatter(raw);
const order = Number.parseInt(data.order ?? "999", 10);
pages.push({
slug,
name: data.nav ?? data.title ?? slug,
href: `/alternatives/${slug}`,
title: data.title ?? slug,
description: data.description ?? "",
content,
order: Number.isFinite(order) ? order : 999,
});
}
pages.sort((a, b) => a.order - b.order);
return pages;
}
let cached: AlternativePage[] | undefined;
export function getAlternativePages(): AlternativePage[] {
if (!cached) cached = loadAlternativePages();
return cached;
}
export function getAlternativePage(slug: string): AlternativePage | undefined {
return getAlternativePages().find((p) => p.slug === slug);
}
export const ALTERNATIVE_PAGE_SLUGS: readonly string[] = getAlternativePages().map((p) => p.slug);

View File

@@ -1,8 +1,17 @@
import Slugger from "github-slugger";
interface DocFrontmatter {
title: string;
description: string;
nav: string;
order: number;
category?: string;
}
export interface DocHeading {
depth: number;
text: string;
id: string;
}
export interface Doc {
@@ -11,8 +20,31 @@ export interface Doc {
sourcePath: string;
frontmatter: DocFrontmatter;
content: string;
headings: DocHeading[];
}
export type DocsNavNode =
| {
type: "category";
label: string;
children: DocsNavNode[];
order: number;
}
| {
type: "group";
segment: string;
label: string;
children: DocsNavNode[];
order: number;
}
| {
type: "page";
segment: string;
label: string;
href: string;
order: number;
};
function parseFrontmatter(raw: string): { data: Record<string, string>; content: string } {
const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) return { data: {}, content: raw };
@@ -32,6 +64,40 @@ function parseFrontmatter(raw: string): { data: Record<string, string>; content:
return { data, content: match[2] };
}
function stripInlineMarkdown(text: string): string {
return text
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
.replace(/!?\[([^\]]*)\]\[[^\]]*\]/g, "$1")
.replace(/`([^`]+)`/g, "$1")
.replace(/(\*\*|__)(.*?)\1/g, "$2")
.replace(/(\*|_)(.*?)\1/g, "$2")
.replace(/<[^>]+>/g, "")
.trim();
}
function parseHeadings(content: string): DocHeading[] {
const slugger = new Slugger();
const headings: DocHeading[] = [];
const regex = /^(#{1,6})\s+(.+)$/gm;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
const depth = match[1].length;
if (depth < 2 || depth > 4) continue;
const text = stripInlineMarkdown(match[2].trim());
if (!text) continue;
headings.push({
depth,
text,
id: slugger.slug(text),
});
}
return headings;
}
const docModules = import.meta.glob("../../../public-docs/**/*.md", {
eager: true,
query: "?raw",
@@ -41,7 +107,8 @@ const docModules = import.meta.glob("../../../public-docs/**/*.md", {
function pathToSlug(path: string): string {
const after = path.split("/public-docs/")[1] ?? path;
const noExt = after.replace(/\.md$/, "");
return noExt === "index" ? "" : noExt;
if (noExt === "index") return "";
return noExt.replace(/\/index$/, "");
}
function pathToSourcePath(path: string): string {
@@ -66,8 +133,10 @@ function loadDocs(): Doc[] {
description: data.description ?? "",
nav: data.nav ?? data.title ?? slug,
order: Number.isFinite(order) ? order : 999,
category: data.category,
},
content,
headings: parseHeadings(content),
});
}
@@ -85,3 +154,129 @@ export function getDocs(): Doc[] {
export function getDoc(slug: string): Doc | undefined {
return getDocs().find((d) => d.slug === slug);
}
function formatLabel(segment: string): string {
return segment.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
}
function insertDocByPath(nodes: DocsNavNode[], doc: Doc): void {
const relative = doc.sourcePath.replace(/^public-docs\//, "");
const segments = relative.replace(/\.md$/, "").split("/");
const fileName = segments.pop() ?? "";
const directories = segments;
let current = nodes;
for (const segment of directories) {
let group = current.find(
(node): node is Extract<DocsNavNode, { type: "group" }> =>
node.type === "group" && node.segment === segment,
);
if (!group) {
group = {
type: "group",
segment,
label: formatLabel(segment),
children: [],
order: doc.frontmatter.order,
};
current.push(group);
}
current = group.children;
}
const pageSegment = fileName === "index" ? (directories.at(-1) ?? "") : fileName;
current.push({
type: "page",
segment: pageSegment,
label: doc.frontmatter.nav,
href: doc.href,
order: doc.frontmatter.order,
});
}
function nodeOrder(node: DocsNavNode): number {
if (node.type === "page") return node.order;
if (node.children.length === 0) return Infinity;
let min = Infinity;
for (const child of node.children) {
const childOrder = nodeOrder(child);
if (childOrder < min) min = childOrder;
}
return min;
}
function sortNodes(nodes: DocsNavNode[]): void {
nodes.sort((a, b) => nodeOrder(a) - nodeOrder(b));
for (const node of nodes) {
if (node.type !== "page") {
sortNodes(node.children);
}
}
}
export function buildDocsNavTree(docs: Doc[]): DocsNavNode[] {
const byCategory = new Map<string, Doc[]>();
const uncategorized: Doc[] = [];
for (const doc of docs) {
const category = doc.frontmatter.category?.trim();
if (category) {
const list = byCategory.get(category) ?? [];
list.push(doc);
byCategory.set(category, list);
} else {
uncategorized.push(doc);
}
}
const root: DocsNavNode[] = [];
for (const doc of uncategorized) {
insertDocByPath(root, doc);
}
for (const [label, docsInCategory] of byCategory) {
const children: DocsNavNode[] = [];
for (const doc of docsInCategory) {
insertDocByPath(children, doc);
}
root.push({
type: "category",
label,
children,
order: nodeOrder({ type: "category", label, children, order: Infinity }),
});
}
sortNodes(root);
return root;
}
function findNodePath(
nodes: DocsNavNode[],
href: string,
path: DocsNavNode[] = [],
): DocsNavNode[] | null {
for (const node of nodes) {
if (node.type === "page" && node.href === href) {
return [...path, node];
}
if (node.type !== "page") {
const found = findNodePath(node.children, href, [...path, node]);
if (found) return found;
}
}
return null;
}
export function getDocBreadcrumbGroups(doc: Doc, tree: DocsNavNode[]): { label: string }[] {
const path = findNodePath(tree, doc.href);
if (!path) return [];
return path
.slice(0, -1)
.filter((node): node is Extract<DocsNavNode, { type: "group" }> => node.type === "group")
.map((node) => ({ label: node.label }));
}

View File

@@ -1,3 +1,4 @@
import { getAlternativePages } from "~/data/alternative-pages";
import { AGENT_PAGES } from "~/data/agent-pages";
import { type Doc, getDocs } from "~/docs";
@@ -27,17 +28,19 @@ function agentLine(agent: (typeof AGENT_PAGES)[number]): string {
return `- [${agent.name}](${SITE_URL}/${agent.slug}): ${agent.subtitle}`;
}
function alternativeLine(page: ReturnType<typeof getAlternativePages>[number]): string {
const description = page.description.trim();
const suffix = description ? `: ${description}` : "";
return `- [${page.title}](${SITE_URL}${page.href})${suffix}`;
}
function topLevelDocs(): Doc[] {
return getDocs().filter((d) => !d.slug.includes("/"));
}
function alternativeDocs(): Doc[] {
return getDocs().filter((d) => d.slug.startsWith("alternatives/"));
}
export function buildLlmsTxt(): string {
const docs = topLevelDocs().map(docLine).join("\n");
const alternatives = alternativeDocs().map(docLine).join("\n");
const alternatives = getAlternativePages().map(alternativeLine).join("\n");
const agents = AGENT_PAGES.map(agentLine).join("\n");
return `${PRODUCT_PREAMBLE}

View File

@@ -61,6 +61,13 @@ import { Route as DocsIndexRouteImport } from "./routes/docs/index";
import { Route as BlogIndexRouteImport } from "./routes/blog/index";
import { Route as DocsSplatRouteImport } from "./routes/docs/$";
import { Route as BlogSplatRouteImport } from "./routes/blog/$";
import { Route as AlternativesSupersetRouteImport } from "./routes/alternatives/superset";
import { Route as AlternativesOpencodeDesktopRouteImport } from "./routes/alternatives/opencode-desktop";
import { Route as AlternativesOpenchamberRouteImport } from "./routes/alternatives/openchamber";
import { Route as AlternativesHappyCoderRouteImport } from "./routes/alternatives/happy-coder";
import { Route as AlternativesConductorRouteImport } from "./routes/alternatives/conductor";
import { Route as AlternativesCodexAppRouteImport } from "./routes/alternatives/codex-app";
import { Route as AlternativesClaudeDesktopRouteImport } from "./routes/alternatives/claude-desktop";
const VtcodeRoute = VtcodeRouteImport.update({
id: "/vtcode",
@@ -322,6 +329,43 @@ const BlogSplatRoute = BlogSplatRouteImport.update({
path: "/$",
getParentRoute: () => BlogRoute,
} as any);
const AlternativesSupersetRoute = AlternativesSupersetRouteImport.update({
id: "/alternatives/superset",
path: "/alternatives/superset",
getParentRoute: () => rootRouteImport,
} as any);
const AlternativesOpencodeDesktopRoute =
AlternativesOpencodeDesktopRouteImport.update({
id: "/alternatives/opencode-desktop",
path: "/alternatives/opencode-desktop",
getParentRoute: () => rootRouteImport,
} as any);
const AlternativesOpenchamberRoute = AlternativesOpenchamberRouteImport.update({
id: "/alternatives/openchamber",
path: "/alternatives/openchamber",
getParentRoute: () => rootRouteImport,
} as any);
const AlternativesHappyCoderRoute = AlternativesHappyCoderRouteImport.update({
id: "/alternatives/happy-coder",
path: "/alternatives/happy-coder",
getParentRoute: () => rootRouteImport,
} as any);
const AlternativesConductorRoute = AlternativesConductorRouteImport.update({
id: "/alternatives/conductor",
path: "/alternatives/conductor",
getParentRoute: () => rootRouteImport,
} as any);
const AlternativesCodexAppRoute = AlternativesCodexAppRouteImport.update({
id: "/alternatives/codex-app",
path: "/alternatives/codex-app",
getParentRoute: () => rootRouteImport,
} as any);
const AlternativesClaudeDesktopRoute =
AlternativesClaudeDesktopRouteImport.update({
id: "/alternatives/claude-desktop",
path: "/alternatives/claude-desktop",
getParentRoute: () => rootRouteImport,
} as any);
export interface FileRoutesByFullPath {
"/": typeof IndexRoute;
@@ -372,6 +416,13 @@ export interface FileRoutesByFullPath {
"/sponsor": typeof SponsorRoute;
"/stakpak": typeof StakpakRoute;
"/vtcode": typeof VtcodeRoute;
"/alternatives/claude-desktop": typeof AlternativesClaudeDesktopRoute;
"/alternatives/codex-app": typeof AlternativesCodexAppRoute;
"/alternatives/conductor": typeof AlternativesConductorRoute;
"/alternatives/happy-coder": typeof AlternativesHappyCoderRoute;
"/alternatives/openchamber": typeof AlternativesOpenchamberRoute;
"/alternatives/opencode-desktop": typeof AlternativesOpencodeDesktopRoute;
"/alternatives/superset": typeof AlternativesSupersetRoute;
"/blog/$": typeof BlogSplatRoute;
"/docs/$": typeof DocsSplatRoute;
"/blog/": typeof BlogIndexRoute;
@@ -424,6 +475,13 @@ export interface FileRoutesByTo {
"/sponsor": typeof SponsorRoute;
"/stakpak": typeof StakpakRoute;
"/vtcode": typeof VtcodeRoute;
"/alternatives/claude-desktop": typeof AlternativesClaudeDesktopRoute;
"/alternatives/codex-app": typeof AlternativesCodexAppRoute;
"/alternatives/conductor": typeof AlternativesConductorRoute;
"/alternatives/happy-coder": typeof AlternativesHappyCoderRoute;
"/alternatives/openchamber": typeof AlternativesOpenchamberRoute;
"/alternatives/opencode-desktop": typeof AlternativesOpencodeDesktopRoute;
"/alternatives/superset": typeof AlternativesSupersetRoute;
"/blog/$": typeof BlogSplatRoute;
"/docs/$": typeof DocsSplatRoute;
"/blog": typeof BlogIndexRoute;
@@ -479,6 +537,13 @@ export interface FileRoutesById {
"/sponsor": typeof SponsorRoute;
"/stakpak": typeof StakpakRoute;
"/vtcode": typeof VtcodeRoute;
"/alternatives/claude-desktop": typeof AlternativesClaudeDesktopRoute;
"/alternatives/codex-app": typeof AlternativesCodexAppRoute;
"/alternatives/conductor": typeof AlternativesConductorRoute;
"/alternatives/happy-coder": typeof AlternativesHappyCoderRoute;
"/alternatives/openchamber": typeof AlternativesOpenchamberRoute;
"/alternatives/opencode-desktop": typeof AlternativesOpencodeDesktopRoute;
"/alternatives/superset": typeof AlternativesSupersetRoute;
"/blog/$": typeof BlogSplatRoute;
"/docs/$": typeof DocsSplatRoute;
"/blog/": typeof BlogIndexRoute;
@@ -535,6 +600,13 @@ export interface FileRouteTypes {
| "/sponsor"
| "/stakpak"
| "/vtcode"
| "/alternatives/claude-desktop"
| "/alternatives/codex-app"
| "/alternatives/conductor"
| "/alternatives/happy-coder"
| "/alternatives/openchamber"
| "/alternatives/opencode-desktop"
| "/alternatives/superset"
| "/blog/$"
| "/docs/$"
| "/blog/"
@@ -587,6 +659,13 @@ export interface FileRouteTypes {
| "/sponsor"
| "/stakpak"
| "/vtcode"
| "/alternatives/claude-desktop"
| "/alternatives/codex-app"
| "/alternatives/conductor"
| "/alternatives/happy-coder"
| "/alternatives/openchamber"
| "/alternatives/opencode-desktop"
| "/alternatives/superset"
| "/blog/$"
| "/docs/$"
| "/blog"
@@ -641,6 +720,13 @@ export interface FileRouteTypes {
| "/sponsor"
| "/stakpak"
| "/vtcode"
| "/alternatives/claude-desktop"
| "/alternatives/codex-app"
| "/alternatives/conductor"
| "/alternatives/happy-coder"
| "/alternatives/openchamber"
| "/alternatives/opencode-desktop"
| "/alternatives/superset"
| "/blog/$"
| "/docs/$"
| "/blog/"
@@ -696,6 +782,13 @@ export interface RootRouteChildren {
SponsorRoute: typeof SponsorRoute;
StakpakRoute: typeof StakpakRoute;
VtcodeRoute: typeof VtcodeRoute;
AlternativesClaudeDesktopRoute: typeof AlternativesClaudeDesktopRoute;
AlternativesCodexAppRoute: typeof AlternativesCodexAppRoute;
AlternativesConductorRoute: typeof AlternativesConductorRoute;
AlternativesHappyCoderRoute: typeof AlternativesHappyCoderRoute;
AlternativesOpenchamberRoute: typeof AlternativesOpenchamberRoute;
AlternativesOpencodeDesktopRoute: typeof AlternativesOpencodeDesktopRoute;
AlternativesSupersetRoute: typeof AlternativesSupersetRoute;
}
declare module "@tanstack/react-router" {
@@ -1064,6 +1157,55 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof BlogSplatRouteImport;
parentRoute: typeof BlogRoute;
};
"/alternatives/superset": {
id: "/alternatives/superset";
path: "/alternatives/superset";
fullPath: "/alternatives/superset";
preLoaderRoute: typeof AlternativesSupersetRouteImport;
parentRoute: typeof rootRouteImport;
};
"/alternatives/opencode-desktop": {
id: "/alternatives/opencode-desktop";
path: "/alternatives/opencode-desktop";
fullPath: "/alternatives/opencode-desktop";
preLoaderRoute: typeof AlternativesOpencodeDesktopRouteImport;
parentRoute: typeof rootRouteImport;
};
"/alternatives/openchamber": {
id: "/alternatives/openchamber";
path: "/alternatives/openchamber";
fullPath: "/alternatives/openchamber";
preLoaderRoute: typeof AlternativesOpenchamberRouteImport;
parentRoute: typeof rootRouteImport;
};
"/alternatives/happy-coder": {
id: "/alternatives/happy-coder";
path: "/alternatives/happy-coder";
fullPath: "/alternatives/happy-coder";
preLoaderRoute: typeof AlternativesHappyCoderRouteImport;
parentRoute: typeof rootRouteImport;
};
"/alternatives/conductor": {
id: "/alternatives/conductor";
path: "/alternatives/conductor";
fullPath: "/alternatives/conductor";
preLoaderRoute: typeof AlternativesConductorRouteImport;
parentRoute: typeof rootRouteImport;
};
"/alternatives/codex-app": {
id: "/alternatives/codex-app";
path: "/alternatives/codex-app";
fullPath: "/alternatives/codex-app";
preLoaderRoute: typeof AlternativesCodexAppRouteImport;
parentRoute: typeof rootRouteImport;
};
"/alternatives/claude-desktop": {
id: "/alternatives/claude-desktop";
path: "/alternatives/claude-desktop";
fullPath: "/alternatives/claude-desktop";
preLoaderRoute: typeof AlternativesClaudeDesktopRouteImport;
parentRoute: typeof rootRouteImport;
};
}
}
@@ -1140,6 +1282,13 @@ const rootRouteChildren: RootRouteChildren = {
SponsorRoute: SponsorRoute,
StakpakRoute: StakpakRoute,
VtcodeRoute: VtcodeRoute,
AlternativesClaudeDesktopRoute: AlternativesClaudeDesktopRoute,
AlternativesCodexAppRoute: AlternativesCodexAppRoute,
AlternativesConductorRoute: AlternativesConductorRoute,
AlternativesHappyCoderRoute: AlternativesHappyCoderRoute,
AlternativesOpenchamberRoute: AlternativesOpenchamberRoute,
AlternativesOpencodeDesktopRoute: AlternativesOpencodeDesktopRoute,
AlternativesSupersetRoute: AlternativesSupersetRoute,
};
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { alternativeRouteOptions } from "~/components/alternative-page";
export const Route = createFileRoute("/alternatives/claude-desktop")(
alternativeRouteOptions("claude-desktop"),
);

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { alternativeRouteOptions } from "~/components/alternative-page";
export const Route = createFileRoute("/alternatives/codex-app")(
alternativeRouteOptions("codex-app"),
);

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { alternativeRouteOptions } from "~/components/alternative-page";
export const Route = createFileRoute("/alternatives/conductor")(
alternativeRouteOptions("conductor"),
);

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { alternativeRouteOptions } from "~/components/alternative-page";
export const Route = createFileRoute("/alternatives/happy-coder")(
alternativeRouteOptions("happy-coder"),
);

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { alternativeRouteOptions } from "~/components/alternative-page";
export const Route = createFileRoute("/alternatives/openchamber")(
alternativeRouteOptions("openchamber"),
);

View File

@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { alternativeRouteOptions } from "~/components/alternative-page";
export const Route = createFileRoute("/alternatives/opencode-desktop")(
alternativeRouteOptions("opencode-desktop"),
);

View File

@@ -0,0 +1,4 @@
import { createFileRoute } from "@tanstack/react-router";
import { alternativeRouteOptions } from "~/components/alternative-page";
export const Route = createFileRoute("/alternatives/superset")(alternativeRouteOptions("superset"));

View File

@@ -7,7 +7,7 @@ export const Route = createFileRoute("/blog")({
function BlogLayout() {
return (
<SiteShell>
<SiteShell width="default">
<Outlet />
</SiteShell>
);

View File

@@ -16,7 +16,7 @@ export const Route = createFileRoute("/changelog")({
function Changelog() {
return (
<SiteShell>
<SiteShell width="default">
<article className="changelog-markdown rounded-xl border border-border bg-card/40 p-6 md:p-8">
<ReactMarkdown>{changelogMarkdown}</ReactMarkdown>
</article>

View File

@@ -22,7 +22,7 @@ type Status = "idle" | "submitting" | "success" | "error";
function Cloud() {
return (
<SiteShell>
<SiteShell width="default">
<h1 className="text-3xl font-medium mb-3">Paseo Cloud</h1>
<p className="text-white/70 leading-relaxed mb-10">
For using Paseo across machines, with a team, or inside a company. Looking for design

View File

@@ -1,55 +1,23 @@
import { createFileRoute, Link, Outlet } from "@tanstack/react-router";
import { createFileRoute, Link, Outlet, useLocation } from "@tanstack/react-router";
import { Menu, X } from "lucide-react";
import { useCallback, useState } from "react";
import { type Doc, getDocs } from "~/docs";
import { useCallback, useMemo, useState } from "react";
import { DocsBreadcrumbs } from "~/components/docs-breadcrumbs";
import { DocsNav } from "~/components/docs-nav";
import { DocsOutline } from "~/components/docs-outline";
import { buildDocsNavTree, getDoc, getDocs } from "~/docs";
import "~/styles.css";
export const Route = createFileRoute("/docs")({
component: DocsLayout,
});
const ACTIVE_OPTIONS_EXACT = { exact: true };
const MOBILE_ACTIVE_PROPS = { className: "text-foreground" };
const DESKTOP_ACTIVE_PROPS = { className: "bg-muted text-foreground" };
interface NavItem {
name: string;
href: string;
}
interface NavGroup {
name: string | null;
items: NavItem[];
}
const GROUP_LABELS: Record<string, string> = {
alternatives: "Alternatives",
};
function groupKey(doc: Doc): string | null {
const idx = doc.slug.indexOf("/");
return idx === -1 ? null : doc.slug.slice(0, idx);
}
function buildNavigation(): NavGroup[] {
const groups = new Map<string | null, NavItem[]>();
for (const doc of getDocs()) {
const key = groupKey(doc);
const items = groups.get(key) ?? [];
items.push({ name: doc.frontmatter.nav, href: doc.href });
groups.set(key, items);
}
const ordered: NavGroup[] = [];
if (groups.has(null)) ordered.push({ name: null, items: groups.get(null)! });
for (const [key, items] of groups) {
if (key === null) continue;
ordered.push({ name: GROUP_LABELS[key] ?? key, items });
}
return ordered;
}
function DocsLayout() {
const groups = buildNavigation();
const location = useLocation();
const tree = useMemo(() => buildDocsNavTree(getDocs()), []);
const slug = location.pathname === "/docs" ? "" : location.pathname.slice("/docs/".length);
const doc = getDoc(slug);
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const toggleMobileNav = useCallback(() => setMobileNavOpen((v) => !v), []);
const closeMobileNav = useCallback(() => setMobileNavOpen(false), []);
@@ -57,7 +25,7 @@ function DocsLayout() {
return (
<div className="min-h-screen bg-background">
{/* Mobile header */}
<header className="md:hidden sticky top-0 z-50 bg-background border-b border-border">
<header className="lg:hidden sticky top-0 z-50 bg-background border-b border-border">
<div className="flex items-center justify-between p-4">
<Link to="/" className="flex items-center gap-3">
<img src="/logo.svg" alt="Paseo" className="w-6 h-6" />
@@ -74,61 +42,34 @@ function DocsLayout() {
</button>
</div>
{mobileNavOpen && (
<nav className="border-t border-border px-4 py-4 space-y-4 max-h-[calc(100dvh-4rem)] overflow-y-auto">
{groups.map((group) => (
<div key={group.name ?? "root"} className="space-y-1">
{group.name && (
<div className="text-sm font-medium text-foreground mb-2">{group.name}</div>
)}
{group.items.map((item) => (
<Link
key={item.href}
to={item.href}
activeOptions={ACTIVE_OPTIONS_EXACT}
onClick={closeMobileNav}
className="block py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
activeProps={MOBILE_ACTIVE_PROPS}
>
{item.name}
</Link>
))}
</div>
))}
<nav className="border-t border-border px-4 py-4 max-h-[calc(100dvh-4rem)] overflow-y-auto">
<DocsNav nodes={tree} mobile onNavigate={closeMobileNav} />
</nav>
)}
</header>
<div className="flex">
<div className="max-w-[90rem] mx-auto flex items-start">
{/* Desktop sidebar */}
<aside className="hidden md:flex md:flex-col w-56 shrink-0 border-r border-border p-6 sticky top-0 h-screen">
<Link to="/" className="flex items-center gap-3 mb-8 shrink-0">
<aside className="hidden lg:block sticky top-0 h-screen w-60 shrink-0 border-r border-border p-6 overflow-y-auto">
<Link to="/" className="flex items-center gap-3 mb-8">
<img src="/logo.svg" alt="Paseo" className="w-6 h-6" />
<span className="text-lg font-medium">Paseo</span>
</Link>
<nav className="flex-1 min-h-0 overflow-y-auto -ml-3 -mr-3 pr-3 space-y-4">
{groups.map((group) => (
<div key={group.name ?? "root"} className="space-y-1">
{group.name && (
<div className="px-3 py-2 text-sm font-medium text-foreground">{group.name}</div>
)}
{group.items.map((item) => (
<Link
key={item.href}
to={item.href}
activeOptions={ACTIVE_OPTIONS_EXACT}
className="block px-3 py-2 text-sm rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
activeProps={DESKTOP_ACTIVE_PROPS}
>
{item.name}
</Link>
))}
</div>
))}
</nav>
<DocsNav nodes={tree} />
</aside>
<main className="flex-1 min-w-0 p-6 md:p-12 max-w-3xl docs-prose">
<Outlet />
{/* Main content */}
<main className="flex-1 min-w-0 px-6 md:px-12 py-8 md:py-12">
<div className="max-w-prose mx-auto">
{doc && <DocsBreadcrumbs doc={doc} tree={tree} />}
<Outlet />
</div>
</main>
{/* Right outline */}
<aside className="hidden xl:block sticky top-0 h-screen w-60 shrink-0 px-2 overflow-y-auto">
{doc && <DocsOutline headings={doc.headings} />}
</aside>
</div>
</div>
);

View File

@@ -33,7 +33,7 @@ function Download() {
const urls = downloadUrls(release);
return (
<SiteShell>
<SiteShell width="default">
<h1 className="text-3xl md:text-4xl font-semibold tracking-tight mb-2">Download</h1>
<p className="text-muted-foreground mb-10">v{version}</p>

View File

@@ -14,7 +14,7 @@ export const Route = createFileRoute("/privacy")({
function Privacy() {
return (
<SiteShell>
<SiteShell width="default">
<h1 className="text-3xl font-medium mb-8">Privacy Policy</h1>
<div className="space-y-6 text-white/70 leading-relaxed">

View File

@@ -14,7 +14,7 @@ export const Route = createFileRoute("/sponsor")({
function Sponsor() {
return (
<SiteShell>
<SiteShell width="default">
<h1 className="text-3xl font-medium tracking-tight mb-8">Sponsor</h1>
{/* Founder note */}

View File

@@ -33,6 +33,12 @@ export default {
return Response.redirect(url.toString(), 301);
}
const altRedirectMatch = url.pathname.match(/^\/docs\/alternatives\/(.+?)\/?$/);
if (altRedirectMatch) {
url.pathname = `/alternatives/${altRedirectMatch[1]}`;
return Response.redirect(url.toString(), 301);
}
if (url.pathname === "/llms.txt") {
return markdownResponse(buildLlmsTxt());
}

View File

@@ -22,8 +22,11 @@ function discoverDocsRoutes(): string[] {
continue;
}
if (!entry.name.endsWith(".md")) continue;
const rel = path.relative(docsDir, full).replace(/\.md$/, "");
if (rel === "index") continue;
const rel = path
.relative(docsDir, full)
.replace(/\.md$/, "")
.replace(/\/index$/, "");
if (rel === "index" || rel === "") continue;
routes.add(`/docs/${rel.split(path.sep).join("/")}`);
}
};
@@ -54,6 +57,17 @@ function discoverAgentRoutes(): string[] {
.map((slug) => `/${slug}`);
}
function discoverAlternativeRoutes(): string[] {
const alternativesDir = path.join(__dirname, "src/routes/alternatives");
if (!fs.existsSync(alternativesDir)) return [];
const slugs = fs
.readdirSync(alternativesDir, { withFileTypes: true })
.filter((entry) => entry.isFile() && entry.name.endsWith(".tsx"))
.map((entry) => entry.name.replace(/\.tsx$/, ""))
.sort();
return ["/alternatives", ...slugs.map((slug) => `/alternatives/${slug}`)];
}
function discoverBlogRoutes(): string[] {
const postsDir = path.join(__dirname, "posts");
if (!fs.existsSync(postsDir)) return ["/blog"];
@@ -73,6 +87,7 @@ const sitemapPages = [
"/download",
"/privacy",
...discoverAgentRoutes(),
...discoverAlternativeRoutes(),
...discoverDocsRoutes(),
...discoverBlogRoutes(),
].map((routePath) => ({

View File

@@ -1,56 +0,0 @@
---
title: Best practices
description: Tips for getting the most out of Paseo and mobile-first agent workflows.
nav: Best practices
order: 17
---
# Best practices
What I've learned from using Paseo daily. Not rules, just patterns that have worked for me.
## Agents replace typing, not thinking
Your role has changed. You're no longer the one writing code line by line. You're the one making decisions: what to build, how it should work, what the architecture looks like. The agent executes, but you direct.
You can't just say "implement feature X" and walk away. You still have to do the hard part: deciding what to build, how it fits into the system, what trade-offs to make. Thinking is not optional. At least for now, agents replace the typing, not the thinking.
## Verification loops
The agent needs a way to verify its work. TDD is one implementation of this pattern: get the agent to write a failing test, verify it fails for the right reasons, then tell it to make the test pass. The agent can loop on its own because it knows what "done" means.
## Invest in tooling
It's not just test runners. For web apps, something like Playwright MCP lets the agent take screenshots and verify UI changes. For a SaaS app I built a CLI that wraps all the business logic so the agent could launch jobs, check statuses, and scrape data without going through the UI.
Code is cheap with coding agents. I would have never written that CLI before because it felt like wasted effort. Now I bootstrap tooling first. It pays off exponentially.
## Agents are cheap
Don't be shy about running multiple agents. Paseo lets you launch agents in isolated worktrees. Kick one off with voice while walking, then kick off another. They work independently. You get a notification when they're done.
## Use voice extensively
It's much more natural to use voice to communicate ideas and pull them out of your brain. The agent will parse and organize your thoughts better than if you try to write the perfect prompt. You don't need to organize anything. Just talk.
Current speech-to-text models are really good. They catch accents, acronyms, technical terms. And even when they don't, the LLM will infer what you meant.
## Understand the type of work
Sometimes you need to plan: design a spec, verify it, get the agent to follow through. Maybe it takes a couple of agents to work through it. Other times it's conversational: kick off a single agent and start talking, asking questions. Match your approach to the task.
## Iterate and refactor often
Don't expect perfect. Expect working. Make it work, make it correct, make it beautiful. Each iteration gets you closer. With tests, refactoring is cheap.
I don't let myself add too many features before stopping to refactor. Sometimes I kick off an agent and have it trace code paths, explain dependencies, show me how modules connect. I make mental notes during code review and circle back.
## Use agents to check agents
If an agent implements something and you ask it to review its own work, it will never find issues. Launch a separate agent with a fresh context to review the first agent's code. It will catch things the first agent missed or glossed over. An agent might say it's done when it's not. Another agent can detect that.
## Learn your agents' quirks
People argue about which model is better. That's the wrong question. Each model has strengths and weaknesses. Knowing them is more useful than chasing benchmarks. Benchmarks don't mean anything. You need to try the models yourself to form an opinion.
I use Claude Code as my main driver because it's quick and uses tools well. But sometimes it jumps to conclusions and gives up too easily. Codex is frustratingly slow but goes deep, doesn't stop, and is methodical. It's also stubborn and too serious. These aren't good or bad traits, just differences you learn to work around. Use the right model for the job.

View File

@@ -2,7 +2,8 @@
title: Claude Code
description: How Paseo runs Claude Code and how Anthropic's usage policy applies.
nav: Claude Code
order: 7
order: 23
category: Providers
---
# Claude Code
@@ -57,4 +58,4 @@ Paseo has first-class terminal support. You can run Claude Code in your terminal
- [Anthropic: Use the Claude Agent SDK with your Claude plan](https://support.claude.com/en/articles/15036540-use-the-claude-agent-sdk-with-your-claude-plan)
- [Custom providers](/docs/custom-providers), for custom binaries, third-party endpoints, or multiple Claude profiles.
- [Supported providers](/docs/supported-providers), for other agents you can run alongside Claude Code.
- [Paseo vs Claude Desktop](/docs/alternatives/claude-desktop), for a feature comparison.
- [Paseo vs Claude Desktop](/alternatives/claude-desktop), for a feature comparison.

View File

@@ -2,7 +2,8 @@
title: CLI
description: "Paseo CLI reference: manage agents, daemons, permissions, and worktrees from your terminal."
nav: CLI
order: 8
order: 3
category: Getting started
---
# CLI

View File

@@ -2,7 +2,8 @@
title: Configuration
description: Configure Paseo via config.json, environment variables, and CLI overrides.
nav: Configuration
order: 14
order: 40
category: Configuration
---
# Configuration

View File

@@ -2,7 +2,8 @@
title: Custom providers
description: Configure custom providers, alternative endpoints, profiles, custom binaries, and ACP agents in ~/.paseo/config.json.
nav: Custom providers
order: 7
order: 22
category: Providers
---
# Custom providers

View File

@@ -3,6 +3,7 @@ title: Getting started
description: Install Paseo and start running coding agents from anywhere.
nav: Getting started
order: 1
category: Getting started
---
# Getting started

View File

@@ -2,7 +2,8 @@
title: Paseo MCP
description: Paseo MCP tools injected into agents.
nav: Paseo MCP
order: 9
order: 30
category: Automation
---
# Paseo MCP

View File

@@ -2,7 +2,8 @@
title: Metadata generation
description: How Paseo uses providers to generate agent titles, branch names, commit messages, and pull request text, and how to configure them.
nav: Metadata generation
order: 15
order: 42
category: Configuration
---
# Metadata generation

View File

@@ -2,7 +2,8 @@
title: Providers
description: How Paseo thinks about coding agents, wrapping existing CLIs, native vs ACP support, and where to go next.
nav: Providers
order: 5
order: 20
category: Providers
---
# Providers

View File

@@ -2,7 +2,8 @@
title: Schedules
description: Run Paseo agents on intervals or cron.
nav: Schedules
order: 11
order: 31
category: Automation
---
# Schedules

View File

@@ -2,7 +2,8 @@
title: Security
description: "Security model for Paseo: architecture overview, connection methods, relay encryption, and best practices."
nav: Security
order: 16
order: 4
category: Getting started
---
# Security

View File

@@ -2,7 +2,8 @@
title: Orchestration skills
description: "Paseo orchestration skills: teach coding agents to spawn, coordinate, and manage other agents using slash commands."
nav: Skills
order: 12
order: 32
category: Automation
---
# Orchestration skills

View File

@@ -2,7 +2,8 @@
title: Supported providers
description: Every coding agent Paseo can launch, natively supported providers and the ACP catalog.
nav: Supported providers
order: 6
order: 21
category: Providers
---
# Supported providers

View File

@@ -2,7 +2,8 @@
title: Updates
description: How Paseo releases work, the difference between stable and beta channels, and how to opt in to earlier updates.
nav: Updates
order: 18
order: 5
category: Getting started
---
# Updates

View File

@@ -2,7 +2,8 @@
title: Voice
description: Paseo voice architecture, local-first model execution, and provider configuration.
nav: Voice
order: 13
order: 41
category: Configuration
---
# Voice

View File

@@ -3,6 +3,7 @@ title: Why Paseo?
description: What Paseo is, what it isn't, and how it fits into your workflow.
nav: Why Paseo?
order: 2
category: Getting started
---
# Why Paseo?

View File

@@ -2,7 +2,8 @@
title: Workspaces
description: Understand Paseo's project, workspace, and session model before setting up agents or git worktrees.
nav: Workspaces
order: 3
order: 10
category: Workspaces
---
# Workspaces

View File

@@ -2,7 +2,8 @@
title: Git worktrees
description: Run agents in isolated git worktrees with setup hooks, scripts, and long-running services.
nav: Git worktrees
order: 4
order: 11
category: Workspaces
---
# Git worktrees