mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-27 05:30:39 +00:00
fix(frontend): make docs search work on desktop and mobile (#6715)
* fix(frontend): make docs search work on desktop and mobile Docs search on deerflow.tech was broken twice over: no build step ever generated the Pagefind index that Nextra's <Search> loads, and the custom docs header that replaces Nextra's navbar never rendered a search box. - `pnpm build` now runs `search:index` (pagefind 1.5.2, pinned) to write public/_pagefind/ (gitignored). - The landing Header and its mobile sheet take an optional `search` slot; only the docs layout fills it, with localized labels and an aria-label. - Each docs page carries a Pagefind `lang` filter and queries are scoped to it, since the root <html lang> is fixed and would mix en/zh results. - globals.css restyles the results panel with app tokens because Nextra's <Head> variables are unset, lifts it above the mobile sheet, and keeps it tappable; the sheet no longer closes before a result navigates and closes after navigation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(frontend): add DESIGN.md describing the shipped design system Records the existing tokens, typography, components, docs (Nextra) overrides, motion, accessibility and copy rules, plus the landing-only decorative exceptions, so UI changes have something to be checked against. Linked from frontend/AGENTS.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(frontend): address Codex review of docs search - Chinese search: the root <html lang> is fixed, so Pagefind indexed zh pages with the English tokenizer and multi-character words (e.g. 偏好) never matched. scripts/build-search-index.mjs now sets each page's lang from its route before indexing, producing separate en-US and zh-CN indexes, and DocsHtmlLang syncs <html lang> at runtime (resetting Pagefind on a language switch) so queries hit the right index. - Mobile menu: reset the open state when the route changes instead of comparing against the saved path, so Back no longer reopens the sheet. - Shortcut: stop mounting a second <Search> in Nextra's unused mobile menu, which captured Cmd/Ctrl+K and focused an offscreen input at 640-767px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(frontend): keep guidance within size budget * fix(frontend): address docs search review feedback Keep Nextra results within the mobile sheet scroll boundary and dismiss same-hash selections by pointer or keyboard. Rebuild Pagefind output without stale fragments and replace UI source-string checks with behavioral coverage. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
parent
9d0509925e
commit
66f00e7b86
5
frontend/.gitignore
vendored
5
frontend/.gitignore
vendored
@ -43,4 +43,7 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
|
||||
# idea files
|
||||
.idea
|
||||
.idea
|
||||
|
||||
# docs search index (generated by `pnpm build`)
|
||||
/public/_pagefind/
|
||||
|
||||
@ -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
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# 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.
|
||||
|
||||
|
||||
148
frontend/DESIGN.md
Normal file
148
frontend/DESIGN.md
Normal file
@ -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 `<Head>`, 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 `<Search>` 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.
|
||||
@ -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
|
||||
|
||||
```
|
||||
|
||||
@ -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",
|
||||
|
||||
76
frontend/pnpm-lock.yaml
generated
76
frontend/pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
@ -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"],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
84
frontend/scripts/build-search-index.mjs
Normal file
84
frontend/scripts/build-search-index.mjs
Normal file
@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Builds the Pagefind index that Nextra's docs <Search> loads from
|
||||
// /_pagefind/. The root layout renders a fixed <html lang>, 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(
|
||||
/<html\b([^>]*?)\blang="[^"]*"/,
|
||||
`<html$1lang="${locale}"`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildSearchIndex({
|
||||
siteDir = SITE_DIR,
|
||||
outputDir = OUTPUT_DIR,
|
||||
} = {}) {
|
||||
try {
|
||||
const { index, errors } = await pagefind.createIndex();
|
||||
if (!index) throw new Error(errors.join("\n"));
|
||||
|
||||
let pages = 0;
|
||||
for await (const file of htmlFiles(siteDir)) {
|
||||
const sourcePath = path.relative(siteDir, file);
|
||||
const html = await readFile(file, "utf8");
|
||||
const locale = localeForPage(sourcePath);
|
||||
const result = await index.addHTMLFile({
|
||||
// Pagefind derives the result URL from sourcePath, relative to the site.
|
||||
url: `/${sourcePath.split(path.sep).join("/")}`,
|
||||
content: locale ? withHtmlLang(html, locale) : html,
|
||||
});
|
||||
if (result.errors.length) throw new Error(result.errors.join("\n"));
|
||||
pages += 1;
|
||||
}
|
||||
|
||||
// Pagefind never deletes older content-hashed fragments. Rebuild this
|
||||
// generated directory so removed pages and previous builds do not accumulate.
|
||||
await rm(outputDir, { recursive: true, force: true });
|
||||
const written = await index.writeFiles({ outputPath: outputDir });
|
||||
if (written.errors.length) throw new Error(written.errors.join("\n"));
|
||||
console.log(`Pagefind: scanned ${pages} HTML files into ${outputDir}`);
|
||||
} finally {
|
||||
await pagefind.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||
) {
|
||||
buildSearchIndex().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@ -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 <html lang> has not synced yet.
|
||||
const search = (
|
||||
<Search
|
||||
className="[&_input]:border-border [&_input]:bg-secondary/60 [&_input]:border"
|
||||
placeholder={t.home.searchDocs}
|
||||
aria-label={t.home.searchDocsLabel}
|
||||
emptyResult={t.home.searchNoResults}
|
||||
errorText={t.home.searchLoadFailed}
|
||||
loading={t.home.searchLoading}
|
||||
searchOptions={{ filters: { lang } }}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<DocsLanguageProvider lang={lang}>
|
||||
@ -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={<Footer className="mt-0" />}
|
||||
i18n={i18n}
|
||||
// ... Your additional layout options
|
||||
>
|
||||
<DocsHtmlLang lang={lang} />
|
||||
<span hidden data-pagefind-filter={`lang:${lang}`} />
|
||||
{children}
|
||||
</Layout>
|
||||
</DocsLanguageProvider>
|
||||
|
||||
40
frontend/src/components/docs/docs-html-lang.tsx
Normal file
40
frontend/src/components/docs/docs-html-lang.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { getLocaleByLang } from "@/core/i18n/locale";
|
||||
|
||||
type PagefindWindow = Window & {
|
||||
pagefind?: { destroy?: () => Promise<void> };
|
||||
};
|
||||
|
||||
// Pagefind picks its per-language index from <html lang> 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 `<html lang>`. 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;
|
||||
}
|
||||
@ -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}
|
||||
</Link>
|
||||
</nav>
|
||||
{search && <div className="hidden w-40 sm:block lg:w-56">{search}</div>}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-0 h-full w-full rounded-full opacity-30 blur-2xl"
|
||||
@ -81,6 +90,7 @@ export async function Header({ className, homeURL, locale }: HeaderProps) {
|
||||
{ href: `/${lang}/docs`, label: t.home.docs },
|
||||
{ href: "/blog/posts", label: t.home.blog },
|
||||
]}
|
||||
search={search}
|
||||
/>
|
||||
<hr className="from-border/0 via-border/70 to-border/0 absolute top-16 right-0 left-0 z-10 m-0 h-px w-full border-none bg-linear-to-r" />
|
||||
</header>
|
||||
|
||||
@ -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<HTMLDivElement>(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 (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetTrigger asChild className="sm:hidden">
|
||||
@ -27,10 +54,51 @@ export function MobileNav({ links }: { links: MobileNavLink[] }) {
|
||||
<MenuIcon className="size-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-64">
|
||||
<SheetContent
|
||||
ref={contentRef}
|
||||
side="right"
|
||||
className="w-64"
|
||||
aria-describedby={undefined}
|
||||
onClick={(event) => {
|
||||
// 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);
|
||||
}}
|
||||
>
|
||||
<SheetHeader>
|
||||
<SheetTitle className="font-serif text-xl">DeerFlow</SheetTitle>
|
||||
</SheetHeader>
|
||||
{search && (
|
||||
// Radix's modal scroll lock only allows descendants of its content.
|
||||
<Portal.Group as="div" target={contentRef}>
|
||||
<div className="px-4 [&_input]:h-11">{search}</div>
|
||||
</Portal.Group>
|
||||
)}
|
||||
<nav className="flex flex-col gap-1 px-4 text-base font-medium">
|
||||
{links.map((link) => (
|
||||
<Link
|
||||
|
||||
@ -300,6 +300,11 @@ export const enUS: Translations = {
|
||||
home: {
|
||||
docs: "Docs",
|
||||
blog: "Blog",
|
||||
searchDocs: "Search docs…",
|
||||
searchDocsLabel: "Search documentation",
|
||||
searchNoResults: "No results. Try fewer or different words.",
|
||||
searchLoadFailed: "Search is unavailable right now.",
|
||||
searchLoading: "Loading…",
|
||||
},
|
||||
|
||||
// Welcome
|
||||
|
||||
@ -215,6 +215,11 @@ export interface Translations {
|
||||
home: {
|
||||
docs: string;
|
||||
blog: string;
|
||||
searchDocs: string;
|
||||
searchDocsLabel: string;
|
||||
searchNoResults: string;
|
||||
searchLoadFailed: string;
|
||||
searchLoading: string;
|
||||
};
|
||||
|
||||
// Welcome
|
||||
|
||||
@ -285,6 +285,11 @@ export const zhCN: Translations = {
|
||||
home: {
|
||||
docs: "文档",
|
||||
blog: "博客",
|
||||
searchDocs: "搜索文档…",
|
||||
searchDocsLabel: "搜索文档",
|
||||
searchNoResults: "未找到结果,请尝试更少或不同的关键词。",
|
||||
searchLoadFailed: "搜索暂时不可用。",
|
||||
searchLoading: "加载中…",
|
||||
},
|
||||
|
||||
// Welcome
|
||||
|
||||
@ -472,3 +472,42 @@ html.dark .shiki span {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Nextra's search panel and highlights use its --nextra-bg and primary color
|
||||
* variables, which only its <Head> component defines. The app never renders
|
||||
* it, so the panel was see-through, the active row's text was unreadable, and
|
||||
* matches rendered white on transparent.
|
||||
* Use the app's tokens instead.
|
||||
*/
|
||||
.nextra-search-results.nextra-scrollbar {
|
||||
background-color: var(--popover);
|
||||
}
|
||||
|
||||
/*
|
||||
* Mobile search results live inside the modal sheet's scroll-lock boundary.
|
||||
* Lift the panel above the sheet and its other controls.
|
||||
*/
|
||||
.nextra-search-results {
|
||||
z-index: 60;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Below `sm` search only lives in the mobile sheet; match its input width. */
|
||||
@media (max-width: 639px) {
|
||||
.nextra-search-results {
|
||||
width: var(--input-width);
|
||||
}
|
||||
}
|
||||
|
||||
.nextra-search-results [role="option"][data-focus] {
|
||||
background-color: var(--accent);
|
||||
color: var(--accent-foreground);
|
||||
}
|
||||
|
||||
.nextra-search-results [role="option"] mark {
|
||||
background-color: color-mix(in oklch, var(--foreground) 12%, transparent);
|
||||
color: inherit;
|
||||
font-weight: 600;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
111
frontend/tests/e2e/docs-search-mobile.spec.ts
Normal file
111
frontend/tests/e2e/docs-search-mobile.spec.ts
Normal file
@ -0,0 +1,111 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.use({ viewport: { width: 390, height: 700 }, hasTouch: true });
|
||||
|
||||
for (const lang of ["en", "zh"]) {
|
||||
test(`mobile ${lang} search results scroll and same-hash selection closes the menu`, async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await page.goto(`/${lang}/docs#result-0`);
|
||||
await expect(page.locator("html")).toHaveAttribute(
|
||||
"lang",
|
||||
lang === "zh" ? "zh-CN" : "en-US",
|
||||
);
|
||||
// Keep the real Nextra combobox, portal, Radix modal and browser scrolling.
|
||||
// Only the search-engine response is deterministic; no generated index is needed.
|
||||
await page.evaluate((language) => {
|
||||
Object.defineProperty(window, "pagefind", {
|
||||
configurable: true,
|
||||
value: {
|
||||
debouncedSearch: async (
|
||||
_query: string,
|
||||
options?: { filters?: { lang?: string } },
|
||||
) => ({
|
||||
results:
|
||||
options?.filters?.lang === language
|
||||
? [
|
||||
{
|
||||
data: async () => ({
|
||||
url: window.location.pathname,
|
||||
meta: { title: "Documentation" },
|
||||
sub_results: Array.from({ length: 40 }, (_, index) => ({
|
||||
url: `${window.location.pathname}#result-${index}`,
|
||||
title: `Result ${index}`,
|
||||
excerpt:
|
||||
"A matching documentation section about sandboxes.",
|
||||
})),
|
||||
}),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
},
|
||||
});
|
||||
}, lang);
|
||||
|
||||
await page.getByRole("button", { name: "Open menu" }).tap();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await dialog.getByRole("combobox").fill("sandbox");
|
||||
const results = page.getByRole("listbox");
|
||||
await expect(results.getByRole("option")).toHaveCount(40);
|
||||
await expect(results).toBeVisible();
|
||||
expect(
|
||||
await results.evaluate((panel) =>
|
||||
Boolean(panel.closest('[role="dialog"]')),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Wait for the sheet/anchored panel's opening transition before gestures.
|
||||
await results.hover({ position: { x: 20, y: 60 } });
|
||||
const bounds = (await results.boundingBox())!;
|
||||
await page.mouse.wheel(0, 300);
|
||||
await expect
|
||||
.poll(() => results.evaluate((panel) => panel.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
const beforeTouch = await results.evaluate((panel) => panel.scrollTop);
|
||||
const client = await context.newCDPSession(page);
|
||||
const point = {
|
||||
x: bounds.x + bounds.width / 2,
|
||||
y: bounds.y + bounds.height - 60,
|
||||
};
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchStart",
|
||||
touchPoints: [point],
|
||||
});
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ ...point, y: point.y - 150 }],
|
||||
});
|
||||
await client.send("Input.dispatchTouchEvent", {
|
||||
type: "touchEnd",
|
||||
touchPoints: [],
|
||||
});
|
||||
await expect
|
||||
.poll(() => results.evaluate((panel) => panel.scrollTop))
|
||||
.toBeGreaterThan(beforeTouch);
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
await results.evaluate((panel) => {
|
||||
panel.scrollTop = 0;
|
||||
});
|
||||
await results.getByRole("option", { name: /^Result 0\b/ }).tap();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page).toHaveURL(new RegExp(`/${lang}/docs#result-0$`));
|
||||
|
||||
await page.getByRole("button", { name: "Open menu" }).tap();
|
||||
const input = dialog.getByRole("combobox");
|
||||
await input.fill("sandbox");
|
||||
await expect(results.getByRole("option")).toHaveCount(40);
|
||||
await input.press("ArrowDown");
|
||||
await input.press("Home");
|
||||
await expect(input).toHaveAttribute(
|
||||
"aria-activedescendant",
|
||||
(await results.getByRole("option").first().getAttribute("id"))!,
|
||||
);
|
||||
await input.press("Enter");
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect(page).toHaveURL(new RegExp(`/${lang}/docs#result-0$`));
|
||||
});
|
||||
}
|
||||
28
frontend/tests/unit/app/docs-search.test.ts
Normal file
28
frontend/tests/unit/app/docs-search.test.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
import { describe, expect, it } from "@rstest/core";
|
||||
|
||||
const FRONTEND_ROOT = path.resolve(__dirname, "../../..");
|
||||
|
||||
function source(relativePath: string) {
|
||||
return readFileSync(path.join(FRONTEND_ROOT, relativePath), "utf8");
|
||||
}
|
||||
|
||||
describe("docs search", () => {
|
||||
it("builds the Pagefind index that Nextra's search loads", () => {
|
||||
const pkg = JSON.parse(source("package.json")) as {
|
||||
scripts: Record<string, string>;
|
||||
devDependencies: Record<string, string>;
|
||||
};
|
||||
|
||||
// Nextra's <Search> imports /_pagefind/pagefind.js at runtime; every build
|
||||
// path (Docker, build-static, serve.sh, CI) goes through `pnpm build`.
|
||||
expect(pkg.scripts.build).toContain("pnpm run search:index");
|
||||
expect(pkg.scripts["search:index"]).toBe(
|
||||
"node scripts/build-search-index.mjs",
|
||||
);
|
||||
expect(pkg.devDependencies.pagefind).toMatch(/^\d+\.\d+\.\d+$/);
|
||||
expect(source(".gitignore")).toContain("/public/_pagefind/");
|
||||
});
|
||||
});
|
||||
151
frontend/tests/unit/components/landing/mobile-nav.dom.test.tsx
Normal file
151
frontend/tests/unit/components/landing/mobile-nav.dom.test.tsx
Normal file
@ -0,0 +1,151 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import type { Window as HappyWindow } from "happy-dom";
|
||||
import { Search } from "nextra/components";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
const navigation = rs.hoisted(() => ({
|
||||
pathname: "/en/docs",
|
||||
push: rs.fn(),
|
||||
}));
|
||||
|
||||
rs.mock("next/navigation", () => ({
|
||||
usePathname: () => navigation.pathname,
|
||||
useRouter: () => ({ push: navigation.push }),
|
||||
}));
|
||||
rs.mock("next/link", () => ({
|
||||
default: (props: ComponentProps<"a">) => <a {...props} />,
|
||||
}));
|
||||
|
||||
import { MobileNav } from "@/components/landing/mobile-nav";
|
||||
|
||||
const links = [{ href: "/en/docs", label: "Docs" }];
|
||||
|
||||
function menu() {
|
||||
return (
|
||||
<MobileNav links={links} search={<Search aria-label="Search docs" />} />
|
||||
);
|
||||
}
|
||||
|
||||
async function openResults() {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
const input = screen.getByRole("combobox", { name: "Search docs" });
|
||||
fireEvent.change(input, { target: { value: "sandbox" } });
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
return screen.findByRole("option", { name: /Sandbox heading/ });
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
navigation.pathname = "/en/docs";
|
||||
navigation.push.mockReset();
|
||||
window.history.replaceState(null, "", "/en/docs#sandbox");
|
||||
// Happy DOM queues a hashchange for replaceState; drain setup before mount.
|
||||
await (window as unknown as HappyWindow).happyDOM.waitUntilComplete();
|
||||
Object.defineProperty(window, "pagefind", {
|
||||
configurable: true,
|
||||
value: {
|
||||
debouncedSearch: async () => ({
|
||||
results: [
|
||||
{
|
||||
data: async () => ({
|
||||
url: "/en/docs",
|
||||
meta: { title: "Docs" },
|
||||
sub_results: [
|
||||
{
|
||||
url: "/en/docs#sandbox",
|
||||
title: "Sandbox heading",
|
||||
excerpt: "Run tools in an isolated sandbox.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
Reflect.deleteProperty(window, "pagefind");
|
||||
rs.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("mobile docs search", () => {
|
||||
it("closes on a route change and can reopen after navigating back", async () => {
|
||||
const { rerender } = render(menu());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
|
||||
navigation.pathname = "/en/docs/harness/sandbox";
|
||||
rerender(menu());
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
navigation.pathname = "/en/docs";
|
||||
rerender(menu());
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps real Nextra results inside the sheet's allowed scroll region", async () => {
|
||||
render(menu());
|
||||
const result = await openResults();
|
||||
const panel = screen.getByRole("listbox");
|
||||
expect(screen.getByRole("dialog").contains(panel)).toBe(true);
|
||||
|
||||
fireEvent.pointerDown(result, { pointerType: "touch" });
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
expect(result.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it("closes after selecting a result at the already selected hash", async () => {
|
||||
render(menu());
|
||||
const result = await openResults();
|
||||
fireEvent.click(result);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(window.location.hash).toBe("#sandbox");
|
||||
});
|
||||
|
||||
it("closes after keyboard selection at the already selected hash", async () => {
|
||||
render(menu());
|
||||
await openResults();
|
||||
const input = screen.getByRole("combobox", { name: "Search docs" });
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
expect(window.location.hash).toBe("#sandbox");
|
||||
});
|
||||
|
||||
it("closes for same-page keyboard navigation that changes the hash", async () => {
|
||||
render(menu());
|
||||
await openResults();
|
||||
fireEvent(window, new HashChangeEvent("hashchange"));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
|
||||
});
|
||||
|
||||
it("does not close while Enter is composing search text", async () => {
|
||||
render(menu());
|
||||
await openResults();
|
||||
const input = screen.getByRole("combobox", { name: "Search docs" });
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
fireEvent.compositionStart(input);
|
||||
fireEvent.keyDown(input, { key: "Enter", isComposing: true });
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
expect((input as HTMLInputElement).value).toBe("sandbox");
|
||||
});
|
||||
|
||||
it("keeps the menu open when a search query has no selected result", () => {
|
||||
render(menu());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Open menu" }));
|
||||
fireEvent.change(screen.getByRole("combobox", { name: "Search docs" }), {
|
||||
target: { value: "another query" },
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
137
frontend/tests/unit/scripts/build-search-index.test.ts
Normal file
137
frontend/tests/unit/scripts/build-search-index.test.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import {
|
||||
mkdtemp,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, rs } from "@rstest/core";
|
||||
|
||||
const api = rs.hoisted(() => ({
|
||||
createIndex: rs.fn(),
|
||||
addHTMLFile: rs.fn(),
|
||||
writeFiles: rs.fn(),
|
||||
close: rs.fn(),
|
||||
}));
|
||||
|
||||
rs.mock("pagefind", () => ({
|
||||
createIndex: api.createIndex,
|
||||
close: api.close,
|
||||
}));
|
||||
|
||||
import {
|
||||
buildSearchIndex,
|
||||
localeForPage,
|
||||
withHtmlLang,
|
||||
} from "../../../scripts/build-search-index.mjs";
|
||||
|
||||
let root: string;
|
||||
let siteDir: string;
|
||||
let outputDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(path.join(tmpdir(), "docs-search-index-"));
|
||||
siteDir = path.join(root, "site");
|
||||
outputDir = path.join(root, "public", "_pagefind");
|
||||
await mkdir(path.join(siteDir, "en", "docs"), { recursive: true });
|
||||
await mkdir(path.dirname(outputDir), { recursive: true });
|
||||
api.createIndex.mockReset().mockResolvedValue({
|
||||
index: { addHTMLFile: api.addHTMLFile, writeFiles: api.writeFiles },
|
||||
errors: [],
|
||||
});
|
||||
api.addHTMLFile.mockReset().mockResolvedValue({ errors: [] });
|
||||
api.writeFiles.mockReset().mockImplementation(async ({ outputPath }) => {
|
||||
await mkdir(outputPath, { recursive: true });
|
||||
// Like Pagefind, only write new hashed assets; leave previous files alone.
|
||||
await writeFile(
|
||||
path.join(outputPath, `fragment-${api.writeFiles.mock.calls.length}`),
|
||||
"indexed content",
|
||||
);
|
||||
return { errors: [] };
|
||||
});
|
||||
api.close.mockReset().mockResolvedValue(undefined);
|
||||
rs.spyOn(console, "log").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
rs.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("docs search index", () => {
|
||||
it("replaces old generated assets on a second build and preserves other public files", async () => {
|
||||
const oldPage = path.join(siteDir, "en", "docs", "old.html");
|
||||
const logo = path.join(root, "public", "logo.txt");
|
||||
await writeFile(oldPage, '<html lang="en-US"><body>Old page</body></html>');
|
||||
await writeFile(logo, "unrelated public asset");
|
||||
await buildSearchIndex({ siteDir, outputDir });
|
||||
expect(await readdir(outputDir)).toEqual(["fragment-1"]);
|
||||
|
||||
await rm(oldPage);
|
||||
await writeFile(
|
||||
path.join(siteDir, "en", "docs", "renamed.html"),
|
||||
'<html lang="en-US"><body>Renamed page</body></html>',
|
||||
);
|
||||
await buildSearchIndex({ siteDir, outputDir });
|
||||
|
||||
expect(await readdir(outputDir)).toEqual(["fragment-2"]);
|
||||
expect(await readFile(logo, "utf8")).toBe("unrelated public asset");
|
||||
expect(api.addHTMLFile.mock.calls.map(([page]) => page.url)).toEqual([
|
||||
"/en/docs/old.html",
|
||||
"/en/docs/renamed.html",
|
||||
]);
|
||||
expect(api.close).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("indexes nested HTML with the route language and leaves other assets out", async () => {
|
||||
await mkdir(path.join(siteDir, "zh", "docs"), { recursive: true });
|
||||
await writeFile(
|
||||
path.join(siteDir, "zh", "docs", "sandbox.html"),
|
||||
'<html lang="en-US" class="theme"><body>沙箱</body></html>',
|
||||
);
|
||||
await writeFile(path.join(siteDir, "en", "docs", "image.png"), "image");
|
||||
await buildSearchIndex({ siteDir, outputDir });
|
||||
expect(api.addHTMLFile).toHaveBeenCalledExactlyOnceWith({
|
||||
url: "/zh/docs/sandbox.html",
|
||||
content: '<html lang="zh-CN" class="theme"><body>沙箱</body></html>',
|
||||
});
|
||||
});
|
||||
|
||||
it("uses one tokenizer per supported route language", () => {
|
||||
expect(localeForPage("zh/docs/harness/memory.html")).toBe("zh-CN");
|
||||
expect(localeForPage("zh.html")).toBe("zh-CN");
|
||||
expect(localeForPage("en/docs/harness/memory.html")).toBe("en-US");
|
||||
expect(localeForPage("blog/posts.html")).toBeUndefined();
|
||||
expect(withHtmlLang('<html lang="en-US" class="theme">', "zh-CN")).toBe(
|
||||
'<html lang="zh-CN" class="theme">',
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the previous index if reading/indexing pages fails, and closes Pagefind", async () => {
|
||||
await mkdir(outputDir, { recursive: true });
|
||||
await writeFile(path.join(outputDir, "pagefind.js"), "previous index");
|
||||
await writeFile(path.join(siteDir, "en", "docs", "page.html"), "page");
|
||||
api.addHTMLFile.mockResolvedValueOnce({ errors: ["indexing failed"] });
|
||||
|
||||
await expect(buildSearchIndex({ siteDir, outputDir })).rejects.toThrow(
|
||||
"indexing failed",
|
||||
);
|
||||
expect(await readFile(path.join(outputDir, "pagefind.js"), "utf8")).toBe(
|
||||
"previous index",
|
||||
);
|
||||
expect(api.writeFiles).not.toHaveBeenCalled();
|
||||
expect(api.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports publication errors and closes Pagefind", async () => {
|
||||
api.writeFiles.mockResolvedValueOnce({ errors: ["write failed"] });
|
||||
await expect(buildSearchIndex({ siteDir, outputDir })).rejects.toThrow(
|
||||
"write failed",
|
||||
);
|
||||
expect(api.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user