Files
vercel__workflow/docs/components/geistdocs/mobile-menu.tsx
Karthik Kalyan ea16d04599 docs: split v4/v5 content trees and fix version switcher end-to-end (#1948)
* docs: split v4/v5 content, fix version switcher end-to-end

## Content restructuring
- Split `docs/content/docs/` into `docs/content/docs/v4/` and
  `docs/content/docs/v5/` so each version is a fully independent
  content tree with no shared-file coupling
- v4 excludes the four pages that are v5-only (AbortController
  cancellation docs and the serializable-abort-controller internal page)
- v5 retains all pages; `preRelease` frontmatter field removed (no
  longer needed now that each version is its own folder)
- Removed `AbortController` / `AbortSignal` from v4 serialization page
  (section moved to v5 only)

## Fumadocs source
- Added `v4docs` and `v5docs` as separate `defineDocs()` collections in
  `source.config.ts`; shared `docsSchema` (no more `preRelease` field)
- `source.ts` exports both `source` (v4, `baseUrl: /docs`) and
  `v5Source` (v5, same base URL)

## Version routing
- `version-source.ts` simplified: `filterPreReleaseFromNodes` and
  `isPreReleaseUrl` logic removed; v4 tree uses `source`, v5 tree uses
  `v5Source` + `rewriteNodeUrls`
- v4 `page.tsx`: removed `preRelease` guard (v4Source has no such pages)
- v5 `page.tsx`: uses `v5Source` for `getPage` / `generateStaticParams`
  / `generateMetadata`; `v5Link` wrapper rewrites `/docs/…` hrefs to
  `/v5/docs/…` so inline MDX links stay in the v5 context

## Versioned cookbook
- Added `app/[lang]/v5/cookbook/` layout + page (mirrors v4 but uses
  `v5Source`, `rewriteCookbookUrlForVersion`, and `V5CookbookLink`)
- `getCookbookTree` accepts a `versionPrefix` parameter; sidebar URLs
  are prefixed accordingly (`/v5/cookbook/…`)
- `cookbook-tree.ts`: added `skipVersions?: string[]` per-recipe field
  for version-specific exclusions; `distributed-abort-controller` is
  marked `skipVersions: ['v5']`

## Version switcher — state & navigation
- New `VersionProvider` context (`hooks/geistdocs/use-version.tsx`)
  backed by `localStorage`: URL is source of truth on versioned pages,
  `localStorage` carries the preference across non-versioned pages
  (cookbook overview, worlds, etc.)
- `VersionSwitcher` uses `useVersion()` context instead of URL-only
  detection; now visible on all pages including cookbook
- `DesktopMenu` and `MobileMenu` use `activeVersion` from context so
  the "Docs" and "Cookbook" navbar links resolve to the correct version
  prefix on every page
- `buildVersionUrl` expanded to handle `/cookbook/…` paths alongside
  `/docs/…`; non-versioned routes (worlds, api) return unchanged
- `switchVersion` does a `HEAD` probe before navigating; falls back to
  the versioned cookbook or docs home if the target page doesn't exist
  in that version (handles v4-only → v5 and v5-only → v4 cases)

## Cookbook content (v5)
- Rewrote `agent-cancellation` recipe using a single `AbortController`
  pattern; removed Hard Cancellation vs Stop Signal two-approach
  comparison
- Deleted `distributed-abort-controller` recipe from v5 (native
  `AbortController` serialization makes it unnecessary)
- Removed references to distributed-abort-controller from
  `cookbook/index.mdx` and `common-patterns/timeouts.mdx`

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(docs): use abortSignal (not signal) in DurableAgent.stream() options

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(docs): update prepack scripts to use versioned content paths

Content moved from docs/content/docs/ to docs/content/docs/v5/ on main
(pre-release channel). Stable branch will use v4/ after backport.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 05:25:15 +00:00

172 lines
4.6 KiB
TypeScript

'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useRef, useState } from 'react';
import { useVersion } from '@/hooks/geistdocs/use-version';
import { IconArrowUpRight } from '@/components/geistcn-fallbacks/geistcn-assets/icons/icon-arrow-up-right';
import { cn } from '@/lib/utils';
import { SearchButton } from './search';
function NavLink({
href,
external,
onClick,
children,
}: {
href: string;
external?: boolean;
onClick?: () => void;
children: React.ReactNode;
}) {
return (
<Link
className="group flex items-center justify-between rounded-md p-3 text-gray-900 transition-colors hover:bg-gray-100 hover:text-gray-1000"
href={href}
onClick={onClick}
rel={external ? 'noopener' : undefined}
target={external ? '_blank' : undefined}
>
{children}
{external && (
<IconArrowUpRight
className="text-gray-900 group-hover:text-gray-1000"
size={16}
/>
)}
</Link>
);
}
function MobileMenuButton({
expanded,
onClick,
}: {
expanded: boolean;
onClick: () => void;
}) {
return (
<button
aria-expanded={expanded}
aria-label={expanded ? 'Close menu' : 'Open menu'}
className="relative flex size-8 items-center justify-center rounded-full border border-gray-200 transition-colors hover:bg-gray-100 lg:hidden"
onClick={onClick}
type="button"
>
<span className="flex flex-col items-center justify-center gap-[5px]">
<span
className={cn(
'block h-[1.5px] w-3.5 bg-gray-1000 transition-all duration-150',
expanded && 'translate-y-[3.25px] rotate-45'
)}
/>
<span
className={cn(
'block h-[1.5px] w-3.5 bg-gray-1000 transition-all duration-150',
expanded && '-translate-y-[3.25px] -rotate-45'
)}
/>
</span>
</button>
);
}
interface MobileMenuProps {
items: { label: string; href: string }[];
}
export const MobileMenu = ({ items }: MobileMenuProps) => {
const [show, setShow] = useState(false);
const pathname = usePathname();
const previousPathname = useRef(pathname);
const { activeVersion } = useVersion();
const resolveHref = (href: string) => {
if (
!href.startsWith('http') &&
(href.startsWith('/docs') || href.startsWith('/cookbook'))
) {
return `${activeVersion.prefix}${href}`;
}
return href;
};
// Close on route change
useEffect(() => {
if (pathname !== previousPathname.current) {
setShow(false);
previousPathname.current = pathname;
}
}, [pathname]);
// Close on escape
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape' && show) {
setShow(false);
}
};
document.addEventListener('keydown', handleEscape);
return () => document.removeEventListener('keydown', handleEscape);
}, [show]);
// Lock scroll when open
useEffect(() => {
document.body.style.overflow = show ? 'hidden' : '';
return () => {
document.body.style.overflow = '';
};
}, [show]);
const close = () => setShow(false);
return (
<>
<MobileMenuButton expanded={show} onClick={() => setShow(!show)} />
{/* Backdrop */}
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: backdrop dismiss */}
{/* biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss */}
{/* biome-ignore lint/a11y/useKeyWithClickEvents: backdrop dismiss */}
<div
className={cn(
'fixed inset-0 top-16 z-40 bg-background-200 backdrop-blur-sm transition-opacity duration-200',
show
? 'pointer-events-auto opacity-100'
: 'pointer-events-none opacity-0'
)}
onClick={close}
/>
{/* Popover */}
<div
className={cn(
'fixed inset-x-0 top-16 bottom-0 z-40 overflow-y-auto bg-background-200 px-2 transition-all duration-200',
show
? 'translate-y-0 opacity-100'
: 'pointer-events-none -translate-y-2 opacity-0'
)}
>
{/* Search */}
<div className="p-4">
<SearchButton className="w-full" onClick={close} />
</div>
{/* Navigation */}
<nav className="px-1">
{items.map(({ label, href }) => (
<NavLink
external={href.startsWith('http')}
href={resolveHref(href)}
key={href}
onClick={close}
>
{label}
</NavLink>
))}
</nav>
</div>
</>
);
};