diff --git a/frontend/.gitignore b/frontend/.gitignore
index c24a8359cf..cd38019705 100644
--- a/frontend/.gitignore
+++ b/frontend/.gitignore
@@ -43,4 +43,7 @@ yarn-error.log*
*.tsbuildinfo
# idea files
-.idea
\ No newline at end of file
+.idea
+
+# docs search index (generated by `pnpm build`)
+/public/_pagefind/
diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md
index 870887b8b8..6589ef9a8d 100644
--- a/frontend/AGENTS.md
+++ b/frontend/AGENTS.md
@@ -203,18 +203,19 @@ NEXT_PUBLIC_LANGGRAPH_BASE_URL=http://localhost:8001/api
Leave these unset for the standard `make dev` / Docker flow, where nginx serves the public `/api/langgraph/*` prefix and rewrites it to Gateway's native `/api/*` routes.
-`make build-static` creates a standalone read-only demo and copies `.next/static`
-and `public` into the output. In static mode, `core/api/static-response.ts`
-resolves Gateway REST reads with the bundled capability catalog and safe
-installation projections from existing same-origin `/mock/api` fixtures; writes
-and unknown API routes fail locally.
-The homepage client counter calls `/github-stars`, outside the Gateway proxy.
-That dynamic route reads the server-only `GITHUB_OAUTH_TOKEN` at runtime, caches
-GitHub data for one hour, and returns 204 when the count is unavailable. Start
-the standalone server from `frontend/` with `node --env-file=.env
-.next/standalone/server.js` to load the current credentials.
+`make build-static` emits a standalone read-only demo, copying `.next/static`
+and `public`. In static mode, `core/api/static-response.ts` serves Gateway reads
+from bundled capabilities and safe installation projections in same-origin
+`/mock/api` fixtures; writes and unknown routes fail locally.
+The homepage counter uses `/github-stars`, outside Gateway. It reads server-only
+`GITHUB_OAUTH_TOKEN` at runtime, caches GitHub data for one hour, and returns 204
+when unavailable. From `frontend/`, run `node --env-file=.env
+.next/standalone/server.js` to load credentials.
-To reach a dev server on anything other than localhost — a LAN address, or a proxied hostname — list the host in `DEER_FLOW_DEV_ALLOWED_ORIGINS` (comma-separated; a full URL is reduced to its host). It feeds Next's `allowedDevOrigins`, which gates `/_next/*`, fonts, and HMR. Without it those requests get a 403 and the page renders server-side but never hydrates, so nothing on it — including the login form — responds. Development only; production builds ignore it.
+For non-localhost dev access, set `DEER_FLOW_DEV_ALLOWED_ORIGINS` to comma-separated
+hosts (URLs reduce to hosts). Next's `allowedDevOrigins` gates `/_next/*`, fonts
+and HMR; otherwise 403s prevent hydration and interactions, including login.
+Production ignores this setting.
One-time schedule input uses `validZonedLocalToUtcIso` to reject wall times that
do not round-trip in the selected timezone. Invalid input emits an empty spec and
@@ -230,6 +231,7 @@ round-trip validation must still reject newly entered skipped times.
## Resources
+- [DESIGN.md](DESIGN.md) — tokens, type, components, a11y rules; read before UI work
- [LangGraph Documentation](https://langchain-ai.github.io/langgraph/)
- [LangChain Core Concepts](https://js.langchain.com/docs/concepts)
- [TanStack Query Documentation](https://tanstack.com/query/latest)
@@ -240,14 +242,19 @@ round-trip validation must still reject newly entered skipped times.
Follow `src/` ownership, add types/error handling and unit/E2E coverage, run
`pnpm check`, and update this guide for architecture, command, or convention changes.
-Route asset budgets are enforced with `pnpm perf:check`. The command measures
-`/login` from a normal production build, then builds in static-demo mode for the
-fixture-backed workspace routes. It starts the production server on temporary local
-ports, measures the unique JavaScript and CSS files referenced by representative
-routes, writes the detailed result to `.next/performance-results.json`, and compares
-totals with `performance-budgets.json`. Fix route ownership or split points when a
-budget fails; do not raise a ceiling without documenting and reviewing the measured
-regression.
+Docs search: `pnpm build` runs `scripts/build-search-index.mjs`: route-based
+language, then replace `public/_pagefind/` (generated). `DocsHtmlLang` syncs
+runtime language. Only `Header.search` renders search (`Layout search={null}`).
+`MobileNav` uses Headless UI `Portal.Group` inside Radix's scroll boundary;
+selections close at the same hash too. Pin behavior with DOM and
+`tests/e2e/docs-search-mobile.spec.ts` tests; index tests use temporary files.
+
+`pnpm perf:check` measures `/login` in a production build and fixture-backed
+workspace routes in a static-demo build. It starts servers on temporary local
+ports, measures unique JavaScript/CSS for representative routes, writes
+`.next/performance-results.json`, and checks `performance-budgets.json`.
+Fix route ownership or split points when over budget; document and review
+measured regressions before raising ceilings.
Chat archive is a thread metadata flag (`deerflow_archived === true`), independent
of run status. Sidebar and Chats explicitly request the Gateway's optional
@@ -338,7 +345,7 @@ shared-MCP administrator checks remain authoritative; this adds no personal scop
-# This is NOT the Next.js you know
+## This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md
new file mode 100644
index 0000000000..90d6b9fee2
--- /dev/null
+++ b/frontend/DESIGN.md
@@ -0,0 +1,148 @@
+# DESIGN.md
+
+The visual system of the DeerFlow frontend, as it ships today. It records what the
+code already does so new UI matches it. The source of truth stays in code:
+tokens in `src/styles/globals.css`, primitives in `src/components/ui/`. When this
+file and the code disagree, fix one of them in the same change.
+
+## Surfaces
+
+The app has three surfaces that share one token set:
+
+| Surface | Routes | Character |
+| ------------------ | ---------------------------------- | ---------------------------------------------------------------------------------- |
+| Workspace (app UI) | `/workspace/*`, `/login`, `/setup` | Dense, calm, task-focused. Most UI work happens here. |
+| Docs | `/[lang]/docs/*` (Nextra 4) | Reading-first. Nextra theme with the landing `Header` in place of Nextra's navbar. |
+| Landing and blog | `/`, `/blog/*` | Brand-forward. The only place decorative effects are allowed. |
+
+## Color
+
+Colors are shadcn-style semantic tokens in OKLCH, defined on `:root` and `.dark`
+in `globals.css` and exposed to Tailwind through `@theme inline`
+(`bg-background`, `text-muted-foreground`, `border-border`, ...).
+
+- **Neutrals carry a warm tint.** Light surfaces use hue 87.47 at chroma 0.0098
+ (paper, not pure white). Dark surfaces use hue 106.64 at chroma 0.0036.
+- **Primary is pure black in light mode and pure white in dark mode.** There is
+ no brand hue; contrast does the work.
+- **The only chromatic UI color is the focus ring** (`--ring`, blue at hue 255)
+ plus `--destructive` (red). Charts use `--chart-1..5`.
+
+| Token | Light | Dark | Use |
+| ---------------------- | ---------------------------- | ------------------------------------ | ----------------------------------- |
+| `--background` | `oklch(0.9855 0.0098 87.47)` | `oklch(0.24 0.0036 106.64)` | Page |
+| `--foreground` | `oklch(0.145 0 0)` | `oklch(0.985 0 0)` | Body text |
+| `--card` / `--popover` | `oklch(1 0.0098 87.47)` | `oklch(0.238 / 0.205 0.0036 106.64)` | Raised surfaces, menus, popovers |
+| `--primary` | `oklch(0 0 0)` | `oklch(1 0 0)` | Primary buttons, strongest emphasis |
+| `--secondary` | `oklch(0.9455 0.0098 87.47)` | `oklch(0.3 0.0036 106.64)` | Secondary buttons, input fills |
+| `--muted-foreground` | `oklch(0.556 0 0)` | `oklch(0.708 0 0)` | Secondary text, captions |
+| `--accent` | `oklch(0.94 0.0098 87.47)` | `oklch(0.32 0.0036 106.64)` | Hover and active rows |
+| `--border` / `--input` | `oklch(0.922 / 0.88 ...)` | `oklch(1 0 0 / 10% / 15%)` | Hairlines, field borders |
+| `--ring` | `oklch(0.58 0.18 255)` | `oklch(0.74 0.16 255)` | Focus outlines |
+| `--destructive` | `oklch(0.577 0.245 27.3)` | `oklch(0.704 0.191 22.2)` | Errors, delete |
+
+Rules:
+
+- Use semantic tokens, never raw hex or Tailwind palette colors, in app and docs
+ UI. Exceptions that exist today are listed under **Known exceptions**.
+- Tints are `color-mix(in oklch, var(--token) N%, transparent)` or Tailwind
+ opacity modifiers (`bg-secondary/60`), so they follow the theme.
+- Theme switching is `next-themes` with `attribute="class"` and system default.
+ Every new color must work in both modes; check both before shipping.
+
+## Typography
+
+- **Sans** (`--font-sans`): the platform UI stack (`ui-sans-serif, system-ui`).
+ Used for all UI and body text.
+- **Serif** (Tailwind `font-serif`): reserved for the **DeerFlow wordmark** and
+ a few brand moments (header, footer quote, auth pages, workspace header).
+ Don't use it for UI labels or body text.
+- **Mono** (`font-mono`): code, paths, IDs, token counts.
+- Dark mode sets `font-weight: 300` on `.dark` to offset light-on-dark bloom.
+ Don't fight it with per-component weights.
+- Sizes are Tailwind's scale. UI text is `text-sm` (14px); body and reading text
+ is `text-base` (16px); don't set body copy smaller than 14px.
+
+## Shape, spacing, elevation
+
+- **Radius**: `--radius` is 10px. `rounded-md` (8px) for controls, `rounded-lg`
+ (10px) for cards and panels, `rounded-xl`+ only for large containers. Keep
+ radii consistent within one component.
+- **Spacing**: Tailwind's 4px scale. Page gutters are `px-4` on mobile.
+- **Width**: `.container-md` steps 40/48/64/80rem; workspace panes use
+ `--container-width-{xs,sm,md,lg}`.
+- **Elevation**: borders before shadows. `shadow-xs` on outline buttons,
+ `shadow-lg` only on overlays (sheets, dialogs, menus).
+
+## Components
+
+Build from `src/components/ui/` (shadcn on Radix) before writing new primitives:
+`Button`, `Input`, `Textarea`, `Select`, `Dialog`, `Sheet`, `DropdownMenu`,
+`Command`, `Tooltip`, `HoverCard`, `Tabs`, `Switch`, `Badge`, `Card`, `Empty`,
+`Skeleton`, `Sonner` (toasts), and more.
+
+- **Buttons** (`button.tsx`): `default` (primary), `outline`, `secondary`,
+ `ghost`, `link`, `destructive`. Sizes `sm` (32px), `default` (36px), `lg`
+ (40px), `icon*`. One `default` button per area; the rest are `outline` or
+ `ghost`.
+- **Icons**: `lucide-react` at `size-4` inside controls. `@radix-ui/react-icons`
+ only where it is already used (e.g. the GitHub mark).
+- **Empty states**: use `Empty` with a sentence that says what to do next, not
+ only "No items."
+- **Loading**: `Skeleton` for layout-shaped loading, a spinner only for short
+ inline waits.
+- **Overlays**: Radix `Sheet`/`Dialog` lock body scrolling and pointer events.
+ Nested portals must stay inside the modal's allowed content region, above
+ z-50, with pointer events enabled. Mobile docs search uses Headless UI's
+ `Portal.Group` targeting the sheet content; z-index alone cannot permit scroll.
+ See `components/landing/mobile-nav.tsx`.
+
+## Docs (Nextra)
+
+- The docs layout renders the landing `Header` instead of Nextra's navbar, and
+ never renders Nextra's `
`, so Nextra's `--nextra-*` variables are unset.
+ Restyle Nextra parts with app tokens in `globals.css` (see the search panel
+ rules) instead of defining `--nextra-*`.
+- Search is Nextra's `` in the header's `search` slot (desktop) and the
+ mobile menu sheet (below `sm`), localized via `t.home.search*`, with an
+ `aria-label`. Matches use a soft foreground tint plus bold, not inverted
+ blocks.
+- Known gap: below 768px there is no control to open the docs sidebar.
+
+## Motion
+
+- Short, functional transitions: `transition-colors` on hover, Radix
+ enter/exit animations, `tw-animate-css` utilities. Durations 150 to 300ms.
+- Named animations live in `@theme` in `globals.css` (`fade-in`, `fade-in-up`,
+ `loading-bar`, `skeleton-entrance`, ...). Reuse them before adding new ones.
+- Respect `prefers-reduced-motion` for anything that loops or moves content.
+
+## Accessibility
+
+- Every input has a visible label or an `aria-label`; placeholders are hints,
+ not labels.
+- Icon-only buttons need `aria-label` (e.g. `aria-label="Open menu"`).
+- Keep the visible focus ring (`focus-visible:ring-ring/50`); never remove
+ outlines without a replacement.
+- Touch targets in mobile-only UI are at least 44px tall.
+- Body text contrast is at least 4.5:1 in both themes; `muted-foreground` is for
+ secondary text only.
+
+## Copy
+
+- Utility language in the app: say what the area is or what the user can do.
+- Every user-visible string goes through `src/core/i18n/locales/` with both
+ `en-US` and `zh-CN` entries.
+- Use the typographic ellipsis (`…`) in placeholders and loading text.
+
+## Known exceptions
+
+Landing-only decoration that does not follow the rules above. Don't copy these
+into app or docs UI:
+
+- `Section` headings use a white-to-gray gradient text (`bg-clip-text`), and
+ `.golden-text` is a gold gradient.
+- `.ambilight` is a rainbow glow, and the header's Star on GitHub button has a
+ pink-to-violet blur behind it (`#ff80b5` to `#9089fc`).
+- `galaxy`, `aurora-text`, `flickering-grid`, `magic-bento`, `spotlight-card`
+ and `shine-border` in `components/ui/` are landing effects.
diff --git a/frontend/README.md b/frontend/README.md
index ed89081da9..5aa4fc3c81 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -68,6 +68,11 @@ pnpm build
pnpm start
```
+Production builds recreate `public/_pagefind/` from the current documentation,
+with separate English and Chinese indexes. The index step replaces its generated
+output directory to prevent old assets accumulating. Mobile docs search results scroll inside
+the menu and close it after selection, including the section already open.
+
## Site Map
```
diff --git a/frontend/package.json b/frontend/package.json
index 69b6bc6131..ae3c2aa917 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -6,7 +6,7 @@
"scripts": {
"catalog:sync": "node scripts/sync-capability-catalog.mjs",
"demo:save": "node scripts/save-demo.js",
- "build": "next build",
+ "build": "next build && pnpm run search:index",
"check": "eslint . --ext .ts,.tsx && tsc --noEmit",
"dev": "node scripts/dev.mjs",
"format": "prettier --check .",
@@ -14,7 +14,8 @@
"lint": "eslint . --ext .ts,.tsx",
"lint:fix": "eslint . --ext .ts,.tsx --fix",
"perf:check": "node scripts/measure-route-assets.mjs --check",
- "preview": "next build && next start",
+ "preview": "pnpm run build && next start",
+ "search:index": "node scripts/build-search-index.mjs",
"start": "next start",
"test": "rstest",
"test:e2e": "playwright test",
@@ -28,6 +29,7 @@
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/language-data": "^6.5.2",
+ "@headlessui/react": "^2.2.9",
"@langchain/core": "^1.1.15",
"@langchain/langgraph-sdk": "^1.5.3",
"@radix-ui/react-avatar": "^1.1.11",
@@ -118,6 +120,7 @@
"eslint": "^9.23.0",
"eslint-config-next": "^15.2.3",
"happy-dom": "^21.0.4",
+ "pagefind": "1.5.2",
"postcss": "^8.5.18",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index da41b3a7b4..502fc87a2a 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -34,6 +34,9 @@ importers:
'@codemirror/language-data':
specifier: ^6.5.2
version: 6.5.2
+ '@headlessui/react':
+ specifier: ^2.2.9
+ version: 2.2.9(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
'@langchain/core':
specifier: ^1.1.15
version: 1.1.20(@opentelemetry/api@1.9.0)
@@ -299,6 +302,9 @@ importers:
happy-dom:
specifier: ^21.0.4
version: 21.0.4
+ pagefind:
+ specifier: 1.5.2
+ version: 1.5.2
postcss:
specifier: ^8.5.18
version: 8.5.25
@@ -1215,6 +1221,41 @@ packages:
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
+ '@pagefind/darwin-arm64@1.5.2':
+ resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@pagefind/darwin-x64@1.5.2':
+ resolution: {integrity: sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==}
+ cpu: [x64]
+ os: [darwin]
+
+ '@pagefind/freebsd-x64@1.5.2':
+ resolution: {integrity: sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@pagefind/linux-arm64@1.5.2':
+ resolution: {integrity: sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==}
+ cpu: [arm64]
+ os: [linux]
+
+ '@pagefind/linux-x64@1.5.2':
+ resolution: {integrity: sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==}
+ cpu: [x64]
+ os: [linux]
+
+ '@pagefind/windows-arm64@1.5.2':
+ resolution: {integrity: sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==}
+ cpu: [arm64]
+ os: [win32]
+
+ '@pagefind/windows-x64@1.5.2':
+ resolution: {integrity: sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==}
+ cpu: [x64]
+ os: [win32]
+
'@playwright/test@1.62.1':
resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==}
engines: {node: '>=20'}
@@ -4855,6 +4896,10 @@ packages:
package-manager-detector@1.6.0:
resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==}
+ pagefind@1.5.2:
+ resolution: {integrity: sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==}
+ hasBin: true
+
pako@0.2.9:
resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==}
@@ -7016,6 +7061,27 @@ snapshots:
'@opentelemetry/api@1.9.0': {}
+ '@pagefind/darwin-arm64@1.5.2':
+ optional: true
+
+ '@pagefind/darwin-x64@1.5.2':
+ optional: true
+
+ '@pagefind/freebsd-x64@1.5.2':
+ optional: true
+
+ '@pagefind/linux-arm64@1.5.2':
+ optional: true
+
+ '@pagefind/linux-x64@1.5.2':
+ optional: true
+
+ '@pagefind/windows-arm64@1.5.2':
+ optional: true
+
+ '@pagefind/windows-x64@1.5.2':
+ optional: true
+
'@playwright/test@1.62.1':
dependencies:
playwright: 1.62.1
@@ -11304,6 +11370,16 @@ snapshots:
package-manager-detector@1.6.0: {}
+ pagefind@1.5.2:
+ optionalDependencies:
+ '@pagefind/darwin-arm64': 1.5.2
+ '@pagefind/darwin-x64': 1.5.2
+ '@pagefind/freebsd-x64': 1.5.2
+ '@pagefind/linux-arm64': 1.5.2
+ '@pagefind/linux-x64': 1.5.2
+ '@pagefind/windows-arm64': 1.5.2
+ '@pagefind/windows-x64': 1.5.2
+
pako@0.2.9: {}
papaparse@5.7.0: {}
diff --git a/frontend/rstest.config.ts b/frontend/rstest.config.ts
index a67b270ffc..401f9bb3d3 100644
--- a/frontend/rstest.config.ts
+++ b/frontend/rstest.config.ts
@@ -15,7 +15,8 @@ const shared = {
output: {
// Streamdown imports KaTeX CSS as a side effect. Bundle these packages so
// Rsbuild processes that CSS import instead of Node trying to load it.
- bundleDependencies: ["streamdown", "katex"],
+ // Nextra's client icons also use extensionless imports that need bundling.
+ bundleDependencies: ["streamdown", "katex", "nextra"],
},
};
diff --git a/frontend/scripts/build-search-index.mjs b/frontend/scripts/build-search-index.mjs
new file mode 100644
index 0000000000..ae86149066
--- /dev/null
+++ b/frontend/scripts/build-search-index.mjs
@@ -0,0 +1,84 @@
+#!/usr/bin/env node
+
+// Builds the Pagefind index that Nextra's docs loads from
+// /_pagefind/. The root layout renders a fixed , so the Pagefind
+// CLI would index every page as English and leave Chinese text unsegmented.
+// Rewrite each page's lang from its /{lang}/ route prefix first, so Pagefind
+// writes one correctly tokenized index per language.
+
+import { readdir, readFile, rm } from "node:fs/promises";
+import path from "node:path";
+import process from "node:process";
+import { fileURLToPath } from "node:url";
+
+import * as pagefind from "pagefind";
+
+const SITE_DIR = ".next/server/app";
+const OUTPUT_DIR = "public/_pagefind";
+const LOCALE_BY_PREFIX = { en: "en-US", zh: "zh-CN" };
+
+async function* htmlFiles(dir) {
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ yield* htmlFiles(fullPath);
+ } else if (entry.name.endsWith(".html")) {
+ yield fullPath;
+ }
+ }
+}
+
+export function localeForPage(relativePath) {
+ const prefix = relativePath.split(/[/\\.]/)[0];
+ return LOCALE_BY_PREFIX[prefix];
+}
+
+export function withHtmlLang(html, locale) {
+ return html.replace(
+ /]*?)\blang="[^"]*"/,
+ ` {
+ console.error(error);
+ process.exitCode = 1;
+ });
+}
diff --git a/frontend/src/app/[lang]/docs/layout.tsx b/frontend/src/app/[lang]/docs/layout.tsx
index 292fefb8ca..d903b10bbe 100644
--- a/frontend/src/app/[lang]/docs/layout.tsx
+++ b/frontend/src/app/[lang]/docs/layout.tsx
@@ -1,13 +1,16 @@
import "katex/dist/katex.min.css";
+import { Search } from "nextra/components";
import { getPageMap } from "nextra/page-map";
import { Layout } from "nextra-theme-docs";
+import { DocsHtmlLang } from "@/components/docs/docs-html-lang";
import { DocsLanguageProvider } from "@/components/docs/docs-language-context";
import { buildLocalizedDocsPageMap } from "@/components/docs/docs-page-map";
import { Footer } from "@/components/landing/footer";
import { Header } from "@/components/landing/header";
import { getLocaleByLang } from "@/core/i18n/locale";
+import { getI18n } from "@/core/i18n/server";
import "nextra-theme-docs/style.css";
const i18n = [
@@ -20,6 +23,20 @@ export default async function DocLayout({ children, params }) {
const locale = getLocaleByLang(lang);
const pages = await getPageMap(`/${lang}`);
const pageMap = buildLocalizedDocsPageMap(`/${lang}/docs`, pages);
+ const { t } = await getI18n(locale);
+ // Pagefind keeps one index per language (see scripts/build-search-index.mjs);
+ // the lang filter also scopes queries if has not synced yet.
+ const search = (
+
+ );
return (
@@ -29,14 +46,20 @@ export default async function DocLayout({ children, params }) {
className="sticky max-w-full px-10"
homeURL="/"
locale={locale}
+ search={search}
/>
}
pageMap={pageMap}
+ // Search lives in the custom Header; Nextra's own mobile menu is never
+ // shown, and a second instance would steal the Cmd/Ctrl+K shortcut.
+ search={null}
docsRepositoryBase="https://github.com/bytedance/deer-flow/tree/main/frontend"
footer={}
i18n={i18n}
// ... Your additional layout options
>
+
+
{children}
diff --git a/frontend/src/components/docs/docs-html-lang.tsx b/frontend/src/components/docs/docs-html-lang.tsx
new file mode 100644
index 0000000000..afb32dc40a
--- /dev/null
+++ b/frontend/src/components/docs/docs-html-lang.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import { useEffect } from "react";
+
+import { getLocaleByLang } from "@/core/i18n/locale";
+
+type PagefindWindow = Window & {
+ pagefind?: { destroy?: () => Promise };
+};
+
+// Pagefind picks its per-language index from when it first
+// initializes, and Nextra keeps the module on `window`. Dropping it makes the
+// next search re-import and re-initialize for the current language.
+function resetPagefind() {
+ const win = window as PagefindWindow;
+ const pagefind = win.pagefind;
+ delete win.pagefind;
+ void pagefind?.destroy?.();
+}
+
+/**
+ * The root layout renders a fixed ``. Docs pages sync it to their
+ * locale so screen readers and Pagefind's language-specific index (built by
+ * `scripts/build-search-index.mjs`) see the right language.
+ */
+export function DocsHtmlLang({ lang }: { lang: string }) {
+ useEffect(() => {
+ const root = document.documentElement;
+ const previous = root.lang;
+ const locale = getLocaleByLang(lang);
+ if (previous === locale) return;
+ root.lang = locale;
+ resetPagefind();
+ return () => {
+ root.lang = previous;
+ resetPagefind();
+ };
+ }, [lang]);
+ return null;
+}
diff --git a/frontend/src/components/landing/header.tsx b/frontend/src/components/landing/header.tsx
index 5de6076ffb..f94cbe5b74 100644
--- a/frontend/src/components/landing/header.tsx
+++ b/frontend/src/components/landing/header.tsx
@@ -1,5 +1,6 @@
import { GitHubLogoIcon } from "@radix-ui/react-icons";
import Link from "next/link";
+import { type ReactNode } from "react";
import { Button } from "@/components/ui/button";
import { DEFAULT_LOCALE, type Locale } from "@/core/i18n/locale";
@@ -14,9 +15,16 @@ export type HeaderProps = {
className?: string;
homeURL?: string;
locale?: Locale;
+ /** Optional search control, e.g. the docs layout's Pagefind search. */
+ search?: ReactNode;
};
-export async function Header({ className, homeURL, locale }: HeaderProps) {
+export async function Header({
+ className,
+ homeURL,
+ locale,
+ search,
+}: HeaderProps) {
const isExternalHome = !homeURL;
const { locale: resolvedLocale, t } = await getI18n(locale ?? DEFAULT_LOCALE);
const lang = resolvedLocale.substring(0, 2);
@@ -51,6 +59,7 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
{t.home.blog}
+ {search &&
{search}
}
diff --git a/frontend/src/components/landing/mobile-nav.tsx b/frontend/src/components/landing/mobile-nav.tsx
index 39243c5039..2aec3ee029 100644
--- a/frontend/src/components/landing/mobile-nav.tsx
+++ b/frontend/src/components/landing/mobile-nav.tsx
@@ -1,8 +1,10 @@
"use client";
+import { Portal } from "@headlessui/react";
import { MenuIcon } from "lucide-react";
import Link from "next/link";
-import { useState } from "react";
+import { usePathname } from "next/navigation";
+import { type ReactNode, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -18,8 +20,33 @@ export type MobileNavLink = {
label: string;
};
-export function MobileNav({ links }: { links: MobileNavLink[] }) {
+export function MobileNav({
+ links,
+ search,
+}: {
+ links: MobileNavLink[];
+ search?: ReactNode;
+}) {
+ const pathname = usePathname();
+ const contentRef = useRef(null);
const [open, setOpen] = useState(false);
+ // Close on navigation (e.g. picking a search result). The docs layout keeps
+ // this component mounted across pages, so reset during render rather than
+ // only hiding the sheet while the path differs.
+ const [openedPath, setOpenedPath] = useState(pathname);
+ if (pathname !== openedPath) {
+ setOpenedPath(pathname);
+ setOpen(false);
+ }
+
+ useEffect(() => {
+ if (!open) return;
+ // Same-page search results only change the hash.
+ const close = () => setOpen(false);
+ window.addEventListener("hashchange", close);
+ return () => window.removeEventListener("hashchange", close);
+ }, [open]);
+
return (
@@ -27,10 +54,51 @@ export function MobileNav({ links }: { links: MobileNavLink[] }) {
-
+ {
+ // A result at the current hash emits neither pathname nor hashchange.
+ // Bubble after the result's own navigation handler has run.
+ if (
+ event.target instanceof Element &&
+ event.target.closest(".nextra-search-results a[href]")
+ ) {
+ setOpen(false);
+ }
+ }}
+ onKeyDownCapture={(event) => {
+ if (event.key !== "Enter" || !(event.target instanceof Element))
+ return;
+ const activeId = event.target.getAttribute("aria-activedescendant");
+ const option = activeId ? document.getElementById(activeId) : null;
+ if (
+ !option?.matches(".nextra-search-results a[href]") ||
+ !contentRef.current?.contains(option)
+ )
+ return;
+ // Headless UI stops Enter propagation. Let its selection handler run
+ // first, and close only if it handled the key (not IME composition).
+ const nativeEvent = event.nativeEvent;
+ // A microtask can run between native capture and bubble listeners.
+ window.setTimeout(() => {
+ if (nativeEvent.defaultPrevented && !nativeEvent.isComposing) {
+ setOpen(false);
+ }
+ }, 0);
+ }}
+ >
DeerFlow
+ {search && (
+ // Radix's modal scroll lock only allows descendants of its content.
+
+