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:
Agustin M. 2026-10-25 14:31:11 +01:00 committed by GitHub
parent 9d0509925e
commit 66f00e7b86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 977 additions and 28 deletions

5
frontend/.gitignore vendored
View File

@ -43,4 +43,7 @@ yarn-error.log*
*.tsbuildinfo
# idea files
.idea
.idea
# docs search index (generated by `pnpm build`)
/public/_pagefind/

View File

@ -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
View 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.

View File

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

View File

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

View File

@ -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: {}

View File

@ -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"],
},
};

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

View File

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

View 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;
}

View File

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

View File

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

View File

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

View File

@ -215,6 +215,11 @@ export interface Translations {
home: {
docs: string;
blog: string;
searchDocs: string;
searchDocsLabel: string;
searchNoResults: string;
searchLoadFailed: string;
searchLoading: string;
};
// Welcome

View File

@ -285,6 +285,11 @@ export const zhCN: Translations = {
home: {
docs: "文档",
blog: "博客",
searchDocs: "搜索文档…",
searchDocsLabel: "搜索文档",
searchNoResults: "未找到结果,请尝试更少或不同的关键词。",
searchLoadFailed: "搜索暂时不可用。",
searchLoading: "加载中…",
},
// Welcome

View File

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

View 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$`));
});
}

View 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/");
});
});

View 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();
});
});

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