cantip 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/app/components/CanvasMount.tsx +62 -0
  4. package/app/components/CodeWrapToggle.tsx +78 -0
  5. package/app/components/FindOnPage.tsx +224 -0
  6. package/app/components/MobileBottomBar.tsx +93 -0
  7. package/app/components/MobileProjectsPanel.tsx +113 -0
  8. package/app/components/PageFloatingMenu.tsx +224 -0
  9. package/app/components/ProjectSwitcher.tsx +124 -0
  10. package/app/components/Search.tsx +930 -0
  11. package/app/components/ShortcutsHelp.tsx +113 -0
  12. package/app/components/Sidebar.tsx +1049 -0
  13. package/app/components/TabBar.tsx +227 -0
  14. package/app/components/Toc.tsx +129 -0
  15. package/app/components/TopBar.tsx +74 -0
  16. package/app/components/theme-toggle.tsx +71 -0
  17. package/app/components/ui/button.tsx +56 -0
  18. package/app/components/ui/card.tsx +55 -0
  19. package/app/components/ui/dropdown-menu.tsx +156 -0
  20. package/app/components/ui/input.tsx +21 -0
  21. package/app/entry.client.tsx +12 -0
  22. package/app/entry.server.tsx +155 -0
  23. package/app/generated/site.ts +19 -0
  24. package/app/generated/slots.ts +10 -0
  25. package/app/generated/theme.generated.css +60 -0
  26. package/app/lib/config/config.server.ts +50 -0
  27. package/app/lib/config/defaults.ts +120 -0
  28. package/app/lib/config/load.ts +82 -0
  29. package/app/lib/config/schema.ts +131 -0
  30. package/app/lib/config/site.ts +43 -0
  31. package/app/lib/content.server.ts +105 -0
  32. package/app/lib/projects.ts +86 -0
  33. package/app/lib/sidebar.server.ts +113 -0
  34. package/app/lib/site.ts +27 -0
  35. package/app/lib/slots.tsx +33 -0
  36. package/app/lib/tabs.tsx +128 -0
  37. package/app/lib/useKeyboardShortcuts.ts +149 -0
  38. package/app/lib/utils.ts +17 -0
  39. package/app/root.tsx +171 -0
  40. package/app/routes/$.tsx +158 -0
  41. package/app/routes/_index.tsx +60 -0
  42. package/app/styles/app.css +461 -0
  43. package/app/styles/obsidian.css +83 -0
  44. package/app/styles/tailwind.css +227 -0
  45. package/cli.js +119 -0
  46. package/components.json +21 -0
  47. package/dist/config.mjs +87 -0
  48. package/dist/generate-content.mjs +1665 -0
  49. package/package.json +112 -0
  50. package/scripts/build-search-index.ts +129 -0
  51. package/scripts/canonical.ts +34 -0
  52. package/scripts/canvas-to-md.ts +73 -0
  53. package/scripts/compile.ts +242 -0
  54. package/scripts/emit-config.ts +163 -0
  55. package/scripts/generate-content.ts +197 -0
  56. package/scripts/obsidian/files.ts +222 -0
  57. package/scripts/obsidian/fs.ts +34 -0
  58. package/scripts/obsidian/generate.ts +36 -0
  59. package/scripts/obsidian/html.ts +17 -0
  60. package/scripts/obsidian/logger.ts +10 -0
  61. package/scripts/obsidian/markdown.ts +56 -0
  62. package/scripts/obsidian/obsidian.ts +229 -0
  63. package/scripts/obsidian/path.ts +60 -0
  64. package/scripts/obsidian/rehype.ts +60 -0
  65. package/scripts/obsidian/remark.ts +712 -0
  66. package/scripts/obsidian/types.ts +31 -0
  67. package/vite.config.ts +62 -0
@@ -0,0 +1,930 @@
1
+ import { useCallback, useEffect, useRef, useState } from 'react'
2
+ import { createPortal } from 'react-dom'
3
+ import { Link, useLocation } from '@remix-run/react'
4
+ import {
5
+ ChevronRight,
6
+ FileText,
7
+ Folder,
8
+ FolderTree,
9
+ Search as SearchIcon,
10
+ WholeWord,
11
+ X,
12
+ } from 'lucide-react'
13
+
14
+ import { getProject } from '~/lib/projects'
15
+ import { t } from '~/lib/site'
16
+ import { cn } from '~/lib/utils'
17
+
18
+ /**
19
+ * Custom shadcn-styled search over Pagefind's JS API.
20
+ *
21
+ * The index + runtime are static assets under `/pagefind/` (generated at build
22
+ * time by scripts/build-search-index.ts). We import `/pagefind/pagefind.js`
23
+ * lazily on first open and call its `search()` directly, then render results
24
+ * with our own markup so they match the rest of the chrome (instead of using
25
+ * Pagefind's prebuilt UI). The same component backs both the desktop top bar
26
+ * and the mobile bottom bar.
27
+ */
28
+
29
+ interface PagefindSubResult {
30
+ title: string
31
+ url: string
32
+ excerpt: string
33
+ }
34
+ interface PagefindData {
35
+ url: string
36
+ meta: { title?: string }
37
+ excerpt: string
38
+ sub_results?: PagefindSubResult[]
39
+ }
40
+ interface PagefindResult {
41
+ id: string
42
+ data: () => Promise<PagefindData>
43
+ }
44
+ /** Options accepted by Pagefind's search/debouncedSearch (the bits we use). */
45
+ interface PagefindSearchOptions {
46
+ filters?: Record<string, string | string[]>
47
+ }
48
+ /** `filters()` returns each filter's values mapped to their result counts. */
49
+ type PagefindFilterCounts = Record<string, Record<string, number>>
50
+ interface PagefindApi {
51
+ search: (
52
+ query: string,
53
+ options?: PagefindSearchOptions,
54
+ ) => Promise<{ results: PagefindResult[] }>
55
+ debouncedSearch: (
56
+ query: string,
57
+ options?: PagefindSearchOptions,
58
+ debounceMs?: number,
59
+ ) => Promise<{ results: PagefindResult[] } | null>
60
+ filters: () => Promise<PagefindFilterCounts>
61
+ preload: (query: string) => void
62
+ }
63
+
64
+ let pagefindPromise: Promise<PagefindApi | null> | null = null
65
+
66
+ /** Load and init the Pagefind runtime once. */
67
+ function loadPagefind(): Promise<PagefindApi | null> {
68
+ if (typeof window === 'undefined') return Promise.resolve(null)
69
+ if (pagefindPromise) return pagefindPromise
70
+ pagefindPromise = (async () => {
71
+ try {
72
+ // Pagefind's runtime is a build-time-generated asset under public/.
73
+ // Vite refuses to import files in public/ if it can see the path as a
74
+ // literal, so build the URL at runtime (from the page origin) to keep
75
+ // the import opaque — it stays a plain browser dynamic import().
76
+ const url = `${window.location.origin}/pagefind/pagefind.js`
77
+ const mod = (await import(/* @vite-ignore */ url)) as PagefindApi & {
78
+ init?: () => Promise<void>
79
+ }
80
+ await mod.init?.()
81
+ return mod
82
+ } catch (err) {
83
+ console.error('Failed to load Pagefind', err)
84
+ pagefindPromise = null
85
+ return null
86
+ }
87
+ })()
88
+ return pagefindPromise
89
+ }
90
+
91
+ /** First path segment of the current route = the project the reader is in. */
92
+ function projectFromPath(pathname: string): string {
93
+ return pathname.replace(/^\/+/, '').split('/')[0] ?? ''
94
+ }
95
+
96
+ /**
97
+ * Display label for a project id. The id is the vault directory (the value
98
+ * Pagefind filters on); show the human name from the project registry instead,
99
+ * falling back to the raw id for any project not in the registry.
100
+ */
101
+ function projectLabel(id: string): string {
102
+ return getProject(id)?.name ?? id
103
+ }
104
+
105
+ /** A node in the directory tree built from the flat list of relative paths. */
106
+ interface DirNode {
107
+ name: string // last path segment (display label)
108
+ path: string // full relative path from the project root
109
+ children: DirNode[]
110
+ }
111
+
112
+ /** Build a nested folder tree from flat relative paths like "a", "a/b", "a/b/c". */
113
+ function buildDirTree(paths: string[]): DirNode[] {
114
+ const roots: DirNode[] = []
115
+ const byPath = new Map<string, DirNode>()
116
+ // Sorting guarantees parents are created before their children.
117
+ for (const full of [...paths].sort()) {
118
+ const segments = full.split('/')
119
+ let prefix = ''
120
+ let siblings = roots
121
+ for (const seg of segments) {
122
+ prefix = prefix ? `${prefix}/${seg}` : seg
123
+ let node = byPath.get(prefix)
124
+ if (!node) {
125
+ node = { name: seg, path: prefix, children: [] }
126
+ byPath.set(prefix, node)
127
+ siblings.push(node)
128
+ }
129
+ siblings = node.children
130
+ }
131
+ }
132
+ return roots
133
+ }
134
+
135
+ /** One row in the inline directory tree; recurses into its children. */
136
+ function DirTreeNode({
137
+ node,
138
+ depth,
139
+ selected,
140
+ onSelect,
141
+ }: {
142
+ node: DirNode
143
+ depth: number
144
+ selected: string
145
+ onSelect: (path: string) => void
146
+ }) {
147
+ const hasChildren = node.children.length > 0
148
+ // Auto-expand any ancestor of the current selection so it's visible on open.
149
+ const [expanded, setExpanded] = useState(
150
+ () => selected === node.path || selected.startsWith(`${node.path}/`),
151
+ )
152
+ const isSelected = selected === node.path
153
+ return (
154
+ <li>
155
+ {/* A single click selects this folder; if it has children it also toggles
156
+ its expansion (open/close), so one click both highlights and drills.
157
+ Leaf folders just get selected. Selection is a draft — committed only
158
+ when the user presses OK in the modal footer. */}
159
+ <button
160
+ type="button"
161
+ onClick={() => {
162
+ onSelect(node.path)
163
+ if (hasChildren) setExpanded((v) => !v)
164
+ }}
165
+ className={cn(
166
+ 'flex w-full items-center gap-1 rounded px-1 py-1 text-left hover:bg-accent',
167
+ isSelected && 'bg-accent text-foreground',
168
+ )}
169
+ style={{ paddingLeft: `${depth * 12 + 4}px` }}
170
+ >
171
+ <ChevronRight
172
+ className={cn(
173
+ 'size-3 shrink-0 transition-transform',
174
+ expanded && 'rotate-90',
175
+ !hasChildren && 'invisible',
176
+ )}
177
+ />
178
+ <Folder className="size-3.5 shrink-0 text-muted-foreground" />
179
+ <span className="truncate">{node.name}</span>
180
+ </button>
181
+ {hasChildren && expanded && (
182
+ <ul>
183
+ {node.children.map((child) => (
184
+ <DirTreeNode
185
+ key={child.path}
186
+ node={child}
187
+ depth={depth + 1}
188
+ selected={selected}
189
+ onSelect={onSelect}
190
+ />
191
+ ))}
192
+ </ul>
193
+ )}
194
+ </li>
195
+ )
196
+ }
197
+
198
+ /** Strip the trailing slash Pagefind preserves from our route URLs for <Link>. */
199
+ function toRoutePath(url: string): string {
200
+ // Pagefind stores the url we indexed ("/krista/.../doc/"); drop any anchor's
201
+ // leading host and keep the path + hash for sub-result deep links.
202
+ return url
203
+ }
204
+
205
+ /** One result row: the page plus its best matching sub-sections. */
206
+ function ResultItem({ data, onNavigate }: { data: PagefindData; onNavigate: () => void }) {
207
+ const title = data.meta.title ?? data.url
208
+ const subs = (data.sub_results ?? []).slice(0, 3)
209
+ return (
210
+ <li className="rounded-md border bg-card">
211
+ <Link
212
+ to={toRoutePath(data.url)}
213
+ onClick={onNavigate}
214
+ className="flex items-start gap-2 rounded-md px-3 py-2 hover:bg-accent"
215
+ >
216
+ <FileText className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
217
+ <span className="min-w-0">
218
+ <span className="block truncate text-sm font-medium">{title}</span>
219
+ <span
220
+ className="mt-0.5 line-clamp-2 block text-xs text-muted-foreground [&_mark]:bg-transparent [&_mark]:font-semibold [&_mark]:text-foreground"
221
+ dangerouslySetInnerHTML={{ __html: data.excerpt }}
222
+ />
223
+ </span>
224
+ </Link>
225
+ {subs.length > 0 && (
226
+ <ul className="border-t px-3 py-1">
227
+ {subs.map((s) => (
228
+ <li key={s.url}>
229
+ <Link
230
+ to={toRoutePath(s.url)}
231
+ onClick={onNavigate}
232
+ className="block truncate rounded px-2 py-1 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
233
+ >
234
+ {s.title}
235
+ </Link>
236
+ </li>
237
+ ))}
238
+ </ul>
239
+ )}
240
+ </li>
241
+ )
242
+ }
243
+
244
+ export function Search({
245
+ className,
246
+ enableShortcut = true,
247
+ trigger,
248
+ }: {
249
+ className?: string
250
+ /**
251
+ * Whether this instance owns the global ⌘K shortcut. The app mounts two
252
+ * Search instances (desktop top bar + mobile bottom bar) at once; only one
253
+ * must handle ⌘K, otherwise both open and the hidden instance's portalled
254
+ * modal (which escapes its `md:hidden` container) covers the visible one with
255
+ * empty state. The mobile instance passes `enableShortcut={false}`.
256
+ */
257
+ enableShortcut?: boolean
258
+ /**
259
+ * Optional custom trigger. Given an `open` callback, it renders the element
260
+ * that opens the modal — lets callers (e.g. the mobile bottom bar) supply a
261
+ * differently-shaped button than the default bordered "Поиск…" pill while
262
+ * reusing all the modal logic. When omitted, the default trigger renders.
263
+ */
264
+ trigger?: (open: () => void) => JSX.Element
265
+ }) {
266
+ const [open, setOpen] = useState(false)
267
+ const [query, setQuery] = useState('')
268
+ const [results, setResults] = useState<PagefindData[]>([])
269
+ const [loading, setLoading] = useState(false)
270
+ const [ready, setReady] = useState(false)
271
+ const [mounted, setMounted] = useState(false)
272
+ // Available filter values + counts from Pagefind (projects and directories).
273
+ const [filterCounts, setFilterCounts] = useState<PagefindFilterCounts | null>(null)
274
+ // Search scope. `project` defaults to the project the reader is currently in,
275
+ // '' means all projects. `dir` is a directory path within the scope ('' = the
276
+ // whole project/site). The user can opt out of the current project via these.
277
+ const [project, setProject] = useState('')
278
+ const [dir, setDir] = useState('')
279
+ // What the user types in the directory box — a path RELATIVE to the selected
280
+ // project (e.g. "требования/управление-клиентами"). Kept separate from the
281
+ // committed `dir` (the full "<project>/<path>" filter value) so typing can show
282
+ // hints without committing an invalid scope on every keystroke.
283
+ const [dirInput, setDirInput] = useState('')
284
+ // Whether the directory input is focused — the folder-picker dropdown shows
285
+ // while focused (even with an empty input, to list top-level folders) and
286
+ // hides on blur / selection.
287
+ const [dirFocused, setDirFocused] = useState(false)
288
+ // Screen-space rect of the directory input, used to position the folder
289
+ // dropdown. The dropdown is portalled to <body> (so it escapes the modal's
290
+ // `overflow-hidden`), which means it needs fixed coordinates rather than being
291
+ // positioned relative to the input.
292
+ const [dirRect, setDirRect] = useState<DOMRect | null>(null)
293
+ const [showTree, setShowTree] = useState(false)
294
+ // Draft folder selection inside the tree modal — a relative path. It's only
295
+ // applied to the actual search scope when the user presses OK; Cancel discards
296
+ // it. Seeded from the current scope each time the modal opens.
297
+ const [treeDraft, setTreeDraft] = useState('')
298
+ const inputRef = useRef<HTMLInputElement>(null)
299
+ const dirInputRef = useRef<HTMLInputElement>(null)
300
+ const location = useLocation()
301
+ const currentProject = projectFromPath(location.pathname)
302
+
303
+ // Portal target only exists on the client; gate the portal on mount.
304
+ useEffect(() => setMounted(true), [])
305
+
306
+ // Kick off loading the runtime as soon as the modal opens, and focus input.
307
+ // If reopening onto a PRESERVED search (a non-empty query survived the last
308
+ // dismiss), keep the query + scope so the user resumes where they left off.
309
+ // Only a fresh open (no query) seeds the scope to the reader's current project.
310
+ useEffect(() => {
311
+ if (!open) return
312
+ if (!query) {
313
+ setProject(currentProject)
314
+ setDir('')
315
+ setDirInput('')
316
+ }
317
+ setShowTree(false)
318
+ loadPagefind().then(async (api) => {
319
+ setReady(!!api)
320
+ if (api) setFilterCounts(await api.filters())
321
+ })
322
+ inputRef.current?.focus()
323
+ // Read currentProject/query only at open time; we don't want to reset the
324
+ // user's scope mid-session if the route changes underneath the open modal.
325
+ // eslint-disable-next-line react-hooks/exhaustive-deps
326
+ }, [open])
327
+
328
+ // Run a (debounced) search whenever the query changes.
329
+ useEffect(() => {
330
+ if (!open) return
331
+ const q = query.trim()
332
+ if (!q) {
333
+ setResults([])
334
+ setLoading(false)
335
+ return
336
+ }
337
+ let cancelled = false
338
+ setLoading(true)
339
+ // Scope the search. A selected directory is the most specific filter and
340
+ // already carries its project prefix, so it stands alone; otherwise fall
341
+ // back to the project filter. Neither set → search everything.
342
+ const filters: Record<string, string> = {}
343
+ if (dir) filters.dir = dir
344
+ else if (project) filters.project = project
345
+ const options: PagefindSearchOptions = Object.keys(filters).length ? { filters } : {}
346
+ loadPagefind().then(async (api) => {
347
+ if (!api || cancelled) return
348
+ const search = await api.debouncedSearch(q, options, 200)
349
+ if (cancelled || !search) return // superseded by a newer keystroke
350
+ const data = await Promise.all(search.results.slice(0, 8).map((r) => r.data()))
351
+ if (cancelled) return
352
+ setResults(data)
353
+ setLoading(false)
354
+ })
355
+ return () => {
356
+ cancelled = true
357
+ }
358
+ }, [query, project, dir, open])
359
+
360
+ // Hide the modal but PRESERVE the query + scope, so reopening (top bar / ⌘K)
361
+ // resumes the same search — the common "that result wasn't it, let me keep
362
+ // looking" flow. Only the transient picker UI (tree/dir dropdown) is reset.
363
+ const dismiss = useCallback(() => {
364
+ setOpen(false)
365
+ setDirFocused(false)
366
+ setShowTree(false)
367
+ }, [])
368
+
369
+ // Wipe to a clean state: empty query, no directory, scope back to the reader's
370
+ // current project. Used by the in-field clear (X) button.
371
+ const reset = useCallback(() => {
372
+ setQuery('')
373
+ setResults([])
374
+ setProject(currentProject)
375
+ setDir('')
376
+ setDirInput('')
377
+ setDirFocused(false)
378
+ setShowTree(false)
379
+ inputRef.current?.focus()
380
+ // currentProject changes with the route; reading it here (clear pressed) is
381
+ // intentional — a fresh search should scope to wherever the reader now is.
382
+ // eslint-disable-next-line react-hooks/exhaustive-deps
383
+ }, [currentProject])
384
+
385
+ // Whether the current input is wrapped in quotes — Pagefind's exact-phrase
386
+ // syntax. Derived from the text itself (not separate state) so the button and
387
+ // the visible input never disagree, even if the user types/edits quotes by hand.
388
+ const isExact = (() => {
389
+ const t = query.trim()
390
+ return t.length >= 2 && t.startsWith('"') && t.endsWith('"')
391
+ })()
392
+
393
+ // Project options (sorted) from the index, for the scope selector.
394
+ const projectOptions = Object.keys(filterCounts?.project ?? {}).sort()
395
+
396
+ // Directory paths within the active project, RELATIVE to it (the "<project>/"
397
+ // prefix stripped). These feed both the type-ahead hints and the tree view.
398
+ // Without a project selected there's nothing to be relative to, so the dir
399
+ // picker is hidden (the project dropdown is the only scope control then).
400
+ const relDirs = (() => {
401
+ if (!project) return [] as string[]
402
+ const prefix = `${project}/`
403
+ return Object.keys(filterCounts?.dir ?? {})
404
+ .filter((d) => d.startsWith(prefix))
405
+ .map((d) => d.slice(prefix.length))
406
+ .filter(Boolean)
407
+ .sort()
408
+ })()
409
+
410
+ // Nested tree of the project's directories, for the inline tree-view picker.
411
+ const dirTree = buildDirTree(relDirs)
412
+
413
+ // Commit a relative directory path as the active scope. Empty → whole project.
414
+ // The committed `dir` filter value is always the full "<project>/<rel>".
415
+ const commitDir = useCallback(
416
+ (rel: string) => {
417
+ // Keep the raw text the user typed verbatim in the box — including any
418
+ // "/" separators they enter to build a nested path. Only normalise
419
+ // (strip leading/trailing slashes) when deriving the committed filter
420
+ // value, so a half-typed "требования/" still searches "требования" but
421
+ // the slash stays visible for the next segment.
422
+ setDirInput(rel)
423
+ const clean = rel.replace(/^\/+|\/+$/g, '')
424
+ setDir(clean && project ? `${project}/${clean}` : '')
425
+ },
426
+ [project],
427
+ )
428
+
429
+ // Measure the directory input's position so the portalled dropdown can be
430
+ // placed right below it in screen space.
431
+ const measureDir = useCallback(() => {
432
+ const el = dirInputRef.current
433
+ setDirRect(el ? el.getBoundingClientRect() : null)
434
+ }, [])
435
+
436
+ // Keep the dropdown anchored while it's open: re-measure on scroll/resize.
437
+ useEffect(() => {
438
+ if (!dirFocused) return
439
+ measureDir()
440
+ const onMove = () => measureDir()
441
+ // Capture phase catches scrolls inside the modal's overflow containers too.
442
+ window.addEventListener('scroll', onMove, true)
443
+ window.addEventListener('resize', onMove)
444
+ return () => {
445
+ window.removeEventListener('scroll', onMove, true)
446
+ window.removeEventListener('resize', onMove)
447
+ }
448
+ }, [dirFocused, measureDir])
449
+
450
+ // Picking a folder from the hints/tree commits it AND, if it has sub-folders,
451
+ // appends a trailing "/" so the user can keep typing the next segment straight
452
+ // away (leaf folders get no dead slash). The filter value is the same either
453
+ // way — commitDir strips the trailing slash when deriving it.
454
+ const selectDir = useCallback(
455
+ (rel: string) => {
456
+ const hasChildren = relDirs.some((d) => d.startsWith(`${rel}/`))
457
+ commitDir(hasChildren ? `${rel}/` : rel)
458
+ },
459
+ [relDirs, commitDir],
460
+ )
461
+
462
+ // Folder picker entries — the directories on the CURRENT level only (like a
463
+ // file browser), not the whole matching subtree. The typed text is split into
464
+ // a committed parent path (everything up to the last "/") and a partial segment
465
+ // being typed (after it). We list the parent's DIRECT children whose name
466
+ // starts with the partial segment. So: empty/"" → top-level folders;
467
+ // "требования/" → children of требования; "требования/да" → children of
468
+ // требования starting with "да". Each entry is a full relative path (so
469
+ // selecting it commits correctly), but we display only the last segment.
470
+ const dirHints = (() => {
471
+ const raw = dirInput.replace(/^\/+/, '') // keep a trailing slash; drop leading
472
+ const slash = raw.lastIndexOf('/')
473
+ const parent = slash === -1 ? '' : raw.slice(0, slash) // committed dir, no trailing /
474
+ const partial = (slash === -1 ? raw : raw.slice(slash + 1)).toLowerCase()
475
+ const childPrefix = parent ? `${parent}/` : ''
476
+ const seen = new Set<string>()
477
+ const entries: string[] = []
478
+ for (const d of relDirs) {
479
+ if (parent && !d.startsWith(childPrefix)) continue
480
+ const rest = d.slice(childPrefix.length)
481
+ if (!rest) continue
482
+ const name = rest.split('/')[0] // first segment below the parent = this level
483
+ const fullPath = `${childPrefix}${name}`
484
+ if (seen.has(fullPath)) continue
485
+ if (partial && !name.toLowerCase().startsWith(partial)) continue
486
+ seen.add(fullPath)
487
+ entries.push(fullPath)
488
+ }
489
+ return entries.sort().slice(0, 12)
490
+ })()
491
+
492
+ // Normalised relative dir (no surrounding slashes) for exact comparisons such
493
+ // as highlighting the active node in the tree.
494
+ const dirRel = dirInput.trim().replace(/^\/+|\/+$/g, '')
495
+
496
+ // Enter in the directory box "accepts" the best match: take the top hint (or,
497
+ // if the text already exactly names a folder, that folder) and complete it via
498
+ // selectDir — adding the trailing "/" when it has children so the user keeps
499
+ // drilling. No match → leave the typed text as the (free-form) scope.
500
+ const completeDir = useCallback(() => {
501
+ if (!dirRel) return
502
+ const exact = relDirs.find((d) => d.toLowerCase() === dirRel.toLowerCase())
503
+ const best = exact ?? dirHints[0]
504
+ if (best) selectDir(best)
505
+ }, [dirRel, relDirs, dirHints, selectDir])
506
+
507
+ // Reset the directory when the project scope changes — a directory from another
508
+ // project would be empty under the new scope.
509
+ const changeProject = useCallback((next: string) => {
510
+ setProject(next)
511
+ setDir('')
512
+ setDirInput('')
513
+ setShowTree(false)
514
+ inputRef.current?.focus()
515
+ }, [])
516
+
517
+ // Toggle exact match by literally adding/removing the surrounding quotes in the
518
+ // input, so the user sees exactly what's being searched. We trim first so the
519
+ // quotes hug the text, then refocus the input for continued typing.
520
+ const toggleExact = useCallback(() => {
521
+ setQuery((q) => {
522
+ const t = q.trim()
523
+ if (t.length >= 2 && t.startsWith('"') && t.endsWith('"')) {
524
+ return t.slice(1, -1)
525
+ }
526
+ return `"${t.replace(/"/g, '')}"`
527
+ })
528
+ inputRef.current?.focus()
529
+ }, [])
530
+
531
+ // Dismiss (not reset) on navigation so the query + scope survive — reopening
532
+ // resumes the same search if the opened result wasn't the right one. Clicking a
533
+ // result fires the <Link>'s onClick (which dismisses), but a result pointing at
534
+ // the current page only changes the hash and a same-tick dismiss can be missed;
535
+ // watching location guarantees the modal hides whenever a result navigates.
536
+ useEffect(() => {
537
+ if (open) dismiss()
538
+ // Only react to location changes, not to `open` toggling on its own.
539
+ // eslint-disable-next-line react-hooks/exhaustive-deps
540
+ }, [location.pathname, location.hash])
541
+
542
+ // Escape to close + lock background scroll while open. When the folder tree
543
+ // modal is open, Escape dismisses just that (one layer at a time) and leaves
544
+ // the search modal open.
545
+ useEffect(() => {
546
+ if (!open) return
547
+ const onKey = (e: KeyboardEvent) => {
548
+ if (e.key !== 'Escape') return
549
+ if (showTree) setShowTree(false)
550
+ else dismiss()
551
+ }
552
+ document.addEventListener('keydown', onKey)
553
+ const prev = document.body.style.overflow
554
+ document.body.style.overflow = 'hidden'
555
+ return () => {
556
+ document.removeEventListener('keydown', onKey)
557
+ document.body.style.overflow = prev
558
+ }
559
+ }, [open, dismiss, showTree])
560
+
561
+ // Global Cmd/Ctrl-K to open — only on the instance that owns the shortcut, so
562
+ // the two mounted Search instances don't both open on one keypress.
563
+ useEffect(() => {
564
+ if (!enableShortcut) return
565
+ const onKey = (e: KeyboardEvent) => {
566
+ if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
567
+ e.preventDefault()
568
+ setOpen(true)
569
+ }
570
+ }
571
+ window.addEventListener('keydown', onKey)
572
+ return () => window.removeEventListener('keydown', onKey)
573
+ }, [enableShortcut])
574
+
575
+ return (
576
+ <>
577
+ {trigger ? (
578
+ trigger(() => setOpen(true))
579
+ ) : (
580
+ <button
581
+ type="button"
582
+ onClick={() => setOpen(true)}
583
+ className={cn(
584
+ 'inline-flex items-center gap-2 rounded-md border bg-background/60 px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground',
585
+ className,
586
+ )}
587
+ aria-label="Поиск"
588
+ >
589
+ <SearchIcon className="size-4 shrink-0" />
590
+ <span className="max-md:hidden">Поиск…</span>
591
+ <kbd className="ml-auto hidden rounded border px-1.5 text-xs md:inline">Ctrl K</kbd>
592
+ </button>
593
+ )}
594
+
595
+ {open &&
596
+ mounted &&
597
+ createPortal(
598
+ // Portalled to <body> so the overlay escapes the top bar's
599
+ // `-translate-x-1/2` transform — a transformed ancestor would
600
+ // otherwise become the containing block for this `fixed` element,
601
+ // trapping the backdrop and width inside the narrow centered bar.
602
+ <div
603
+ className="fixed inset-0 z-[200] bg-background/40 backdrop-blur-sm md:flex md:items-start md:justify-center md:p-4 md:pt-[10vh]"
604
+ onMouseDown={dismiss}
605
+ role="dialog"
606
+ aria-modal="true"
607
+ aria-label="Поиск по документации"
608
+ >
609
+ <div
610
+ className="fixed inset-0 flex flex-col overflow-hidden bg-popover md:static md:max-h-[80vh] md:w-full md:max-w-3xl md:rounded-lg md:border md:shadow-xl"
611
+ onMouseDown={(e) => e.stopPropagation()}
612
+ >
613
+ {/* Search field */}
614
+ <div className="flex items-center gap-2 border-b px-3">
615
+ <SearchIcon className="size-4 shrink-0 text-muted-foreground" />
616
+ <input
617
+ ref={inputRef}
618
+ value={query}
619
+ onChange={(e) => setQuery(e.target.value)}
620
+ placeholder="Поиск по документации…"
621
+ className="h-12 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
622
+ autoComplete="off"
623
+ spellCheck={false}
624
+ />
625
+ <button
626
+ type="button"
627
+ onClick={toggleExact}
628
+ className={cn(
629
+ 'rounded p-1 hover:bg-accent hover:text-foreground',
630
+ isExact ? 'bg-accent text-foreground' : 'text-muted-foreground',
631
+ )}
632
+ aria-label="Точное совпадение"
633
+ aria-pressed={isExact}
634
+ title="Точное совпадение фразы"
635
+ >
636
+ <WholeWord className="size-4" />
637
+ </button>
638
+ {/* Clear to a clean state (empty query, scope back to the current
639
+ project) without leaving search — for starting a fresh search.
640
+ The modal still closes via Escape, the backdrop, or ⌘K toggling.
641
+ Disabled when there's nothing to clear. */}
642
+ <button
643
+ type="button"
644
+ onClick={reset}
645
+ disabled={!query && !dir}
646
+ className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
647
+ aria-label="Очистить поиск"
648
+ title="Очистить"
649
+ >
650
+ <X className="size-4" />
651
+ </button>
652
+ </div>
653
+
654
+ {/* Scope: project dropdown + a relative directory path box with
655
+ type-ahead hints and an inline tree-view picker. Defaults to
656
+ the reader's current project; "Все проекты" opts out site-wide. */}
657
+ {projectOptions.length > 0 && (
658
+ <div className="border-b px-3 py-2 text-xs text-muted-foreground">
659
+ <div className="flex flex-wrap items-center gap-2">
660
+ <span className="shrink-0">Искать в:</span>
661
+ <select
662
+ value={project}
663
+ onChange={(e) => changeProject(e.target.value)}
664
+ className="rounded border bg-background px-2 py-1 text-foreground outline-none focus:ring-1 focus:ring-ring"
665
+ aria-label={t('searchProjectFilter')}
666
+ >
667
+ <option value="">Все проекты</option>
668
+ {projectOptions.map((p) => (
669
+ <option key={p} value={p}>
670
+ {projectLabel(p)}
671
+ </option>
672
+ ))}
673
+ </select>
674
+
675
+ {/* Directory box only makes sense scoped to one project. */}
676
+ {project && relDirs.length > 0 && (
677
+ <div className="relative flex min-w-0 flex-1 items-center gap-1">
678
+ <div className="relative min-w-0 flex-1">
679
+ <input
680
+ ref={dirInputRef}
681
+ value={dirInput}
682
+ onChange={(e) => commitDir(e.target.value)}
683
+ onFocus={() => setDirFocused(true)}
684
+ onBlur={() => setDirFocused(false)}
685
+ onKeyDown={(e) => {
686
+ // Enter completes the best matching folder (adds "/" if it
687
+ // has children). preventDefault so it doesn't bubble to any
688
+ // form submit / close the modal.
689
+ if (e.key === 'Enter') {
690
+ e.preventDefault()
691
+ completeDir()
692
+ } else if (e.key === 'Escape' && dirFocused) {
693
+ // Let Escape dismiss the folder list first, before it
694
+ // bubbles up and closes the whole search modal.
695
+ e.preventDefault()
696
+ e.stopPropagation()
697
+ setDirFocused(false)
698
+ }
699
+ }}
700
+ placeholder="Папка (напр. требования/управление-клиентами)"
701
+ className="w-full rounded border bg-background px-2 py-1 text-foreground outline-none placeholder:text-muted-foreground focus:ring-1 focus:ring-ring"
702
+ aria-label="Папка"
703
+ autoComplete="off"
704
+ spellCheck={false}
705
+ />
706
+ {dirInput && (
707
+ <button
708
+ type="button"
709
+ onClick={() => commitDir('')}
710
+ className="absolute right-1 top-1/2 -translate-y-1/2 rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
711
+ aria-label="Очистить папку"
712
+ >
713
+ <X className="size-3" />
714
+ </button>
715
+ )}
716
+ {/* Folder picker: the directories on the current level. Shows
717
+ while the input is focused (incl. empty → top-level folders),
718
+ closes on blur (click outside) or picking a leaf folder.
719
+ Portalled to <body> and positioned in screen space so the
720
+ modal's `overflow-hidden` can't clip it. */}
721
+ {dirFocused &&
722
+ dirHints.length > 0 &&
723
+ dirRect &&
724
+ createPortal(
725
+ <ul
726
+ className="fixed z-[220] max-h-48 overflow-y-auto rounded-md border bg-popover py-1 shadow-md"
727
+ style={{
728
+ top: dirRect.bottom + 4,
729
+ left: dirRect.left,
730
+ width: dirRect.width,
731
+ }}
732
+ // Keep focus in the input: stop the list's mousedown from
733
+ // blurring it (the row handlers also preventDefault).
734
+ onMouseDown={(e) => e.preventDefault()}
735
+ >
736
+ {dirHints.map((path) => {
737
+ // Display only this level's folder name; the value is the
738
+ // full relative path so selecting commits correctly.
739
+ const name = path.slice(path.lastIndexOf('/') + 1)
740
+ return (
741
+ <li key={path}>
742
+ <button
743
+ type="button"
744
+ onMouseDown={(e) => {
745
+ // onMouseDown (not onClick) so the input's blur
746
+ // doesn't dismiss the list before we commit.
747
+ e.preventDefault()
748
+ // Folders with children: append "/" and KEEP the list
749
+ // open (now showing the children) so the user drills
750
+ // further. Leaf folders: final pick, so close the list.
751
+ // Either way keep focus on the dir box.
752
+ const hasChildren = relDirs.some((d) =>
753
+ d.startsWith(`${path}/`),
754
+ )
755
+ selectDir(path)
756
+ if (!hasChildren) setDirFocused(false)
757
+ dirInputRef.current?.focus()
758
+ }}
759
+ className="flex w-full items-center gap-1.5 truncate px-2 py-1 text-left text-foreground hover:bg-accent"
760
+ >
761
+ <Folder className="size-3.5 shrink-0 text-muted-foreground" />
762
+ <span className="truncate">{name}</span>
763
+ </button>
764
+ </li>
765
+ )
766
+ })}
767
+ </ul>,
768
+ document.body,
769
+ )}
770
+ </div>
771
+ <button
772
+ type="button"
773
+ onClick={() => {
774
+ // Seed the draft with the current scope so the modal opens
775
+ // with that folder pre-selected; OK commits, Cancel discards.
776
+ setTreeDraft(dirRel)
777
+ setShowTree(true)
778
+ }}
779
+ className={cn(
780
+ 'shrink-0 rounded border p-1 hover:bg-accent hover:text-foreground',
781
+ showTree ? 'bg-accent text-foreground' : 'text-muted-foreground',
782
+ )}
783
+ aria-label="Выбрать папку из дерева"
784
+ aria-pressed={showTree}
785
+ title="Дерево папок"
786
+ >
787
+ <FolderTree className="size-4" />
788
+ </button>
789
+ </div>
790
+ )}
791
+ </div>
792
+ </div>
793
+ )}
794
+
795
+ {/* Results */}
796
+ <div className="min-h-0 flex-1 overflow-y-auto p-2">
797
+ {!query.trim() ? (
798
+ <p className="px-3 py-6 text-center text-sm text-muted-foreground">
799
+ {ready ? 'Введите запрос для поиска' : 'Загрузка индекса…'}
800
+ </p>
801
+ ) : loading && results.length === 0 ? (
802
+ <p className="px-3 py-6 text-center text-sm text-muted-foreground">Поиск…</p>
803
+ ) : results.length === 0 ? (
804
+ <p className="px-3 py-6 text-center text-sm text-muted-foreground">
805
+ Ничего не найдено по запросу «{query.trim()}»
806
+ </p>
807
+ ) : (
808
+ <ul className="flex flex-col gap-1.5">
809
+ {results.map((d) => (
810
+ <ResultItem key={d.url} data={d} onNavigate={dismiss} />
811
+ ))}
812
+ </ul>
813
+ )}
814
+ </div>
815
+
816
+ {/* Mobile-only footer: the full-screen sheet has no backdrop to
817
+ tap, so give an explicit way out. Hidden from md up, where the
818
+ backdrop + Escape dismiss the centered card. */}
819
+ <div className="flex justify-end border-t p-3 md:hidden">
820
+ <button
821
+ type="button"
822
+ onClick={dismiss}
823
+ className="rounded-md border px-4 py-2 text-sm font-medium hover:bg-accent hover:text-foreground"
824
+ >
825
+ {t('close')}
826
+ </button>
827
+ </div>
828
+ </div>
829
+ </div>,
830
+ document.body,
831
+ )}
832
+
833
+ {/* Folder tree picker — its own modal layered above the search modal.
834
+ Opened by the FolderTree button; selecting a folder commits it as
835
+ the scope and closes this modal (the search modal stays open). */}
836
+ {open &&
837
+ mounted &&
838
+ showTree &&
839
+ project &&
840
+ createPortal(
841
+ <div
842
+ className="fixed inset-0 z-[210] bg-background/40 backdrop-blur-sm md:flex md:items-start md:justify-center md:p-4 md:pt-[12vh]"
843
+ onMouseDown={() => setShowTree(false)}
844
+ role="dialog"
845
+ aria-modal="true"
846
+ aria-label="Выбор папки"
847
+ >
848
+ <div
849
+ className="fixed inset-0 flex flex-col overflow-hidden bg-popover md:static md:max-h-[70vh] md:w-full md:max-w-md md:rounded-lg md:border md:shadow-xl"
850
+ onMouseDown={(e) => e.stopPropagation()}
851
+ >
852
+ <div className="flex items-center justify-between gap-2 border-b px-3 py-2">
853
+ <span className="flex items-center gap-2 text-sm font-medium">
854
+ <FolderTree className="size-4 shrink-0 text-muted-foreground" />
855
+ Папка в «{projectLabel(project)}»
856
+ </span>
857
+ {/* Top-right X cancels, like the backdrop — no scope change. */}
858
+ <button
859
+ type="button"
860
+ onClick={() => setShowTree(false)}
861
+ className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
862
+ aria-label={t('close')}
863
+ >
864
+ <X className="size-4" />
865
+ </button>
866
+ </div>
867
+ <div className="min-h-0 flex-1 overflow-y-auto p-1">
868
+ {dirTree.length > 0 ? (
869
+ <ul>
870
+ <li>
871
+ {/* Selecting "весь проект" drafts an empty path (whole project). */}
872
+ <button
873
+ type="button"
874
+ onClick={() => setTreeDraft('')}
875
+ className={cn(
876
+ 'flex w-full items-center gap-1.5 rounded px-1 py-1 text-left hover:bg-accent',
877
+ !treeDraft && 'bg-accent text-foreground',
878
+ )}
879
+ >
880
+ <Folder className="size-3.5 shrink-0 text-muted-foreground" />
881
+ <span className="truncate">{projectLabel(project)} (весь проект)</span>
882
+ </button>
883
+ </li>
884
+ {dirTree.map((node) => (
885
+ <DirTreeNode
886
+ key={node.path}
887
+ node={node}
888
+ depth={1}
889
+ selected={treeDraft}
890
+ onSelect={setTreeDraft}
891
+ />
892
+ ))}
893
+ </ul>
894
+ ) : (
895
+ <p className="px-3 py-6 text-center text-sm text-muted-foreground">
896
+ В этом проекте нет вложенных папок
897
+ </p>
898
+ )}
899
+ </div>
900
+ {/* Footer: Cancel discards the draft (and closes — the mobile
901
+ sheet's way out, since there's no backdrop to tap); OK applies
902
+ it to the scope. Buttons stretch full-width on mobile for easy
903
+ tapping, compact + right-aligned from md up. */}
904
+ <div className="flex items-center gap-2 border-t px-3 py-2 max-md:py-3 md:justify-end">
905
+ <button
906
+ type="button"
907
+ onClick={() => setShowTree(false)}
908
+ className="flex-1 rounded-md border px-3 py-1.5 text-sm hover:bg-accent hover:text-foreground max-md:py-2 md:flex-none"
909
+ >
910
+ Отмена
911
+ </button>
912
+ <button
913
+ type="button"
914
+ onClick={() => {
915
+ commitDir(treeDraft)
916
+ setShowTree(false)
917
+ inputRef.current?.focus()
918
+ }}
919
+ className="flex-1 rounded-md border border-transparent bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 max-md:py-2 md:flex-none"
920
+ >
921
+ ОК
922
+ </button>
923
+ </div>
924
+ </div>
925
+ </div>,
926
+ document.body,
927
+ )}
928
+ </>
929
+ )
930
+ }