boltdocs 2.2.0 → 2.3.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 (81) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/bin/boltdocs.js +2 -2
  3. package/dist/base-ui/index.d.mts +3 -3
  4. package/dist/base-ui/index.d.ts +3 -3
  5. package/dist/base-ui/index.js +1 -1
  6. package/dist/base-ui/index.mjs +1 -1
  7. package/dist/{cache-CRAZ55X7.mjs → cache-P6WK424C.mjs} +1 -1
  8. package/dist/chunk-22NXDNP4.mjs +74 -0
  9. package/dist/chunk-2HUVMMJU.mjs +1 -0
  10. package/dist/chunk-2Z5T6EAU.mjs +1 -0
  11. package/dist/chunk-CRZGOE32.mjs +1 -0
  12. package/dist/chunk-HA6543SL.mjs +1 -0
  13. package/dist/{chunk-ZK2266IZ.mjs → chunk-RPUERTVC.mjs} +1 -1
  14. package/dist/{chunk-MZBG4N4W.mjs → chunk-URTD6E6S.mjs} +1 -1
  15. package/dist/chunk-W2NB4T6V.mjs +1 -0
  16. package/dist/chunk-Y4RRHPXC.mjs +1 -0
  17. package/dist/client/index.d.mts +1 -1
  18. package/dist/client/index.d.ts +1 -1
  19. package/dist/client/index.js +1 -1
  20. package/dist/client/index.mjs +1 -1
  21. package/dist/client/ssr.js +1 -1
  22. package/dist/client/ssr.mjs +1 -1
  23. package/dist/hooks/index.d.mts +7 -15
  24. package/dist/hooks/index.d.ts +7 -15
  25. package/dist/hooks/index.js +1 -1
  26. package/dist/hooks/index.mjs +1 -1
  27. package/dist/{loading-chS3pm9W.d.ts → loading-B7X5Wchs.d.ts} +3 -5
  28. package/dist/{loading-BqGrFWO5.d.mts → loading-WuaQbsKb.d.mts} +3 -5
  29. package/dist/mdx/index.js +1 -1
  30. package/dist/mdx/index.mjs +1 -1
  31. package/dist/node/cli-entry.js +27 -23
  32. package/dist/node/cli-entry.mjs +5 -1
  33. package/dist/node/index.js +10 -10
  34. package/dist/node/index.mjs +1 -1
  35. package/dist/primitives/index.d.mts +2 -2
  36. package/dist/primitives/index.d.ts +2 -2
  37. package/dist/primitives/index.js +1 -1
  38. package/dist/primitives/index.mjs +1 -1
  39. package/dist/search-dialog-ZRXBAQJ5.mjs +1 -0
  40. package/package.json +2 -1
  41. package/src/client/app/theme-context.tsx +14 -7
  42. package/src/client/components/default-layout.tsx +6 -2
  43. package/src/client/components/primitives/navbar.tsx +3 -3
  44. package/src/client/components/primitives/search-dialog.tsx +4 -4
  45. package/src/client/components/primitives/sidebar.tsx +3 -2
  46. package/src/client/components/primitives/skeleton.tsx +26 -0
  47. package/src/client/components/ui-base/loading.tsx +43 -73
  48. package/src/client/components/ui-base/navbar.tsx +8 -6
  49. package/src/client/components/ui-base/page-nav.tsx +2 -1
  50. package/src/client/components/ui-base/powered-by.tsx +4 -1
  51. package/src/client/components/ui-base/search-dialog.tsx +16 -5
  52. package/src/client/components/ui-base/sidebar.tsx +4 -2
  53. package/src/client/hooks/use-i18n.ts +3 -2
  54. package/src/client/hooks/use-localized-to.ts +6 -5
  55. package/src/client/hooks/use-page-nav.ts +27 -6
  56. package/src/client/hooks/use-routes.ts +2 -1
  57. package/src/client/hooks/use-search.ts +81 -59
  58. package/src/client/hooks/use-sidebar.ts +2 -1
  59. package/src/client/store/use-boltdocs-store.ts +6 -5
  60. package/src/client/theme/neutral.css +29 -0
  61. package/src/node/{cli.ts → cli/build.ts} +17 -23
  62. package/src/node/cli/dev.ts +22 -0
  63. package/src/node/cli/doctor.ts +243 -0
  64. package/src/node/cli/index.ts +9 -0
  65. package/src/node/cli/ui.ts +54 -0
  66. package/src/node/cli-entry.ts +16 -16
  67. package/src/node/config.ts +1 -1
  68. package/src/node/plugin/entry.ts +1 -1
  69. package/src/node/plugin/index.ts +24 -10
  70. package/src/node/routes/parser.ts +9 -9
  71. package/src/node/search/index.ts +55 -0
  72. package/src/node/ssg/index.ts +14 -6
  73. package/src/node/ssg/robots.ts +7 -4
  74. package/dist/chunk-5D6XPYQ3.mjs +0 -74
  75. package/dist/chunk-6QXCKZAT.mjs +0 -1
  76. package/dist/chunk-H4M6P3DM.mjs +0 -1
  77. package/dist/chunk-JXHNX2WN.mjs +0 -1
  78. package/dist/chunk-Q3MLYTIQ.mjs +0 -1
  79. package/dist/chunk-RSII2UPE.mjs +0 -1
  80. package/dist/chunk-ZRJ55GGF.mjs +0 -1
  81. package/dist/search-dialog-MA5AISC7.mjs +0 -1
@@ -0,0 +1,26 @@
1
+ import { cn } from '@client/utils/cn'
2
+
3
+ interface SkeletonProps extends React.HTMLAttributes<HTMLDivElement> {
4
+ variant?: 'rect' | 'circle'
5
+ }
6
+
7
+ /**
8
+ * A flexible skeleton component that mimics the shape of content
9
+ * while it is loading. Features a smooth pulse animation.
10
+ */
11
+ export function Skeleton({
12
+ className,
13
+ variant = 'rect',
14
+ ...props
15
+ }: SkeletonProps) {
16
+ return (
17
+ <div
18
+ className={cn(
19
+ 'animate-pulse bg-bg-muted',
20
+ variant === 'circle' ? 'rounded-full' : 'rounded-md',
21
+ className,
22
+ )}
23
+ {...props}
24
+ />
25
+ )
26
+ }
@@ -1,85 +1,55 @@
1
- import { useEffect, useState } from 'react'
1
+ import { cn } from '@client/utils/cn'
2
+ import { Skeleton } from '@primitives/skeleton'
2
3
 
3
4
  /**
4
- * A premium loading component that includes an SVG fill animation
5
- * and a synchronized progress indicator.
6
- *
7
- * It features a glassmorphism container and a Bolt-style SVG logo
8
- * with a dynamic fill effect.
5
+ * A premium loading component that only skeletons the markdown content area.
6
+ * Designed to be used as a Suspense fallback within a persistent layout.
9
7
  */
10
8
  export function Loading() {
11
- const [progress, setProgress] = useState(0)
12
-
13
- useEffect(() => {
14
- let currentProgress = 0
15
- let up = true
9
+ return (
10
+ <div
11
+ className={cn(
12
+ 'w-full h-full relative overflow-y-auto transition-opacity duration-300 animate-fade-in',
13
+ )}
14
+ >
15
+ <div className="mx-auto max-w-(--spacing-content-max) px-4 py-8 space-y-10">
16
+ {/* Breadcrumbs */}
17
+ <div className="flex gap-2">
18
+ <Skeleton className="h-3 w-16" />
19
+ <Skeleton className="h-3 w-24" />
20
+ </div>
16
21
 
17
- const interval = setInterval(() => {
18
- if (up) {
19
- currentProgress += 1
20
- if (currentProgress >= 100) {
21
- currentProgress = 100
22
- up = false
23
- }
24
- } else {
25
- currentProgress -= 1
26
- if (currentProgress <= 0) {
27
- currentProgress = 0
28
- up = true
29
- }
30
- }
31
- setProgress(currentProgress)
32
- }, 20)
22
+ {/* Page Title */}
23
+ <Skeleton className="h-10 w-[60%] sm:h-12" />
33
24
 
34
- return () => clearInterval(interval)
35
- }, [])
25
+ {/* Intro Paragraph */}
26
+ <div className="space-y-3">
27
+ <Skeleton className="h-4 w-full" />
28
+ <Skeleton className="h-4 w-[95%]" />
29
+ <Skeleton className="h-4 w-[40%]" />
30
+ </div>
36
31
 
37
- const clipPathValue = `inset(${100 - progress}% 0 0 0)`
32
+ {/* Section 1 */}
33
+ <div className="space-y-6 pt-4">
34
+ <Skeleton className="h-7 w-32" />
35
+ <div className="space-y-3">
36
+ <Skeleton className="h-4 w-full" />
37
+ <Skeleton className="h-4 w-[98%]" />
38
+ <Skeleton className="h-4 w-[92%]" />
39
+ <Skeleton className="h-4 w-[60%]" />
40
+ </div>
41
+ </div>
38
42
 
39
- return (
40
- <div className="flex flex-col items-center justify-center min-h-[60vh] p-4 text-center">
41
- <div className="relative group">
42
- <div className="relative inline-block">
43
- {/* SVG Background (Dimmed Base) */}
44
- <svg
45
- className="w-24 h-auto opacity-10 filter grayscale brightness-50"
46
- viewBox="0 0 60 51"
47
- fill="none"
48
- xmlns="http://www.w3.org/2000/svg"
49
- role="img"
50
- aria-hidden="true"
51
- >
52
- <title>Loading indicator background</title>
53
- <path
54
- d="M29.4449 0H19.4449V16.5L29.4449 6.5V0Z"
55
- fill="currentColor"
56
- />
57
- <path
58
- d="M26.9449 22.7265C26.9449 22.5077 21.2201 27.0658 16.9449 28.5C13.7491 29.5721 12.3156 29.5038 8.94486 29.5C5.59532 29.4963 0 28.5 0 28.5C0 28.5 5.57953 28.5146 8.94486 27.5C12.5409 26.4158 14.8203 25.5843 17.9449 23.5C23.3445 19.898 29.4449 11.5 29.4449 11.5L29.9449 18C29.9449 18 33.5825 15.8308 36.4449 15C39.4452 14.1291 44.4449 14 44.4449 14C44.4449 14 36.9449 19 34.4449 21.5C31.5322 24.4126 29.8582 26.9017 29.4449 31C29.1217 34.2041 29.4771 36.4508 31.4449 39C33.5792 41.765 35.952 43.0183 39.4449 43C42.677 42.9831 45.3003 42.4182 47.4449 40C49.7406 37.4113 50.2495 34.4466 49.9449 31C49.6603 27.7804 48.4876 25.4953 45.9449 23.5C43.2931 21.4191 36.4449 24 36.4449 24L47.9449 15C47.9449 15 51.5761 16.771 53.4449 18.5C55.711 20.5967 56.7467 22.1546 57.9449 25C59.1784 27.9295 59.4832 29.8216 59.4449 33C59.4089 35.9867 59.179 37.78 57.9449 40.5C56.8475 42.9185 55.8511 44.6507 53.9449 46.5C51.9236 48.4609 50.5803 49.0076 47.9449 50C45.5414 50.9051 44.0131 51 41.4449 51C38.8766 51 37.3235 50.9685 34.9449 50C32.4851 48.9985 29.4449 46 29.4449 46V51H19.4449V37.9904L22.9449 31.4226L26.9449 22.7265Z"
59
- fill="currentColor"
60
- />
61
- </svg>
43
+ {/* Code Block Placeholder */}
44
+ <Skeleton className="h-32 w-full rounded-lg bg-bg-muted/50" />
62
45
 
63
- {/* SVG Animated (Vibrant Fill Overlay) */}
64
- <svg
65
- className="absolute top-0 left-0 w-24 h-auto text-primary-500 drop-shadow-[0_0_20px_rgba(var(--primary-rgb),0.5)] transition-[clip-path] duration-100 ease-linear"
66
- style={{ clipPath: clipPathValue }}
67
- viewBox="0 0 60 51"
68
- fill="none"
69
- xmlns="http://www.w3.org/2000/svg"
70
- role="img"
71
- aria-hidden="true"
72
- >
73
- <title>Loading indicator animated fill</title>
74
- <path
75
- d="M29.4449 0H19.4449V16.5L29.4449 6.5V0Z"
76
- fill="currentColor"
77
- />
78
- <path
79
- d="M26.9449 22.7265C26.9449 22.5077 21.2201 27.0658 16.9449 28.5C13.7491 29.5721 12.3156 29.5038 8.94486 29.5C5.59532 29.4963 0 28.5 0 28.5C0 28.5 5.57953 28.5146 8.94486 27.5C12.5409 26.4158 14.8203 25.5843 17.9449 23.5C23.3445 19.898 29.4449 11.5 29.4449 11.5L29.9449 18C29.9449 18 33.5825 15.8308 36.4449 15C39.4452 14.1291 44.4449 14 44.4449 14C44.4449 14 36.9449 19 34.4449 21.5C31.5322 24.4126 29.8582 26.9017 29.4449 31C29.1217 34.2041 29.4771 36.4508 31.4449 39C33.5792 41.765 35.952 43.0183 39.4449 43C42.677 42.9831 45.3003 42.4182 47.4449 40C49.7406 37.4113 50.2495 34.4466 49.9449 31C49.6603 27.7804 48.4876 25.4953 45.9449 23.5C43.2931 21.4191 36.4449 24 36.4449 24L47.9449 15C47.9449 15 51.5761 16.771 53.4449 18.5C55.711 20.5967 56.7467 22.1546 57.9449 25C59.1784 27.9295 59.4832 29.8216 59.4449 33C59.4089 35.9867 59.179 37.78 57.9449 40.5C56.8475 42.9185 55.8511 44.6507 53.9449 46.5C51.9236 48.4609 50.5803 49.0076 47.9449 50C45.5414 50.9051 44.0131 51 41.4449 51C38.8766 51 37.3235 50.9685 34.9449 50C32.4851 48.9985 29.4449 46 29.4449 46V51H19.4449V37.9904L22.9449 31.4226L26.9449 22.7265Z"
80
- fill="currentColor"
81
- />
82
- </svg>
46
+ {/* Section 2 */}
47
+ <div className="space-y-6 pt-4">
48
+ <Skeleton className="h-7 w-48" />
49
+ <div className="space-y-3">
50
+ <Skeleton className="h-4 w-full" />
51
+ <Skeleton className="h-4 w-[85%]" />
52
+ </div>
83
53
  </div>
84
54
  </div>
85
55
  </div>
@@ -33,12 +33,14 @@ export function Navbar() {
33
33
  <NavbarPrimitive.NavbarRoot className={hasTabs ? 'border-b-0' : ''}>
34
34
  <NavbarPrimitive.Content>
35
35
  <NavbarPrimitive.NavbarLeft>
36
- <NavbarPrimitive.NavbarLogo
37
- src={logo ?? ''}
38
- alt={logoProps?.alt || title}
39
- width={logoProps?.width ?? 24}
40
- height={logoProps?.height ?? 24}
41
- />
36
+ {logo && (
37
+ <NavbarPrimitive.NavbarLogo
38
+ src={logo}
39
+ alt={logoProps?.alt || title}
40
+ width={logoProps?.width ?? 24}
41
+ height={logoProps?.height ?? 24}
42
+ />
43
+ )}
42
44
  <NavbarPrimitive.Title>{title}</NavbarPrimitive.Title>
43
45
 
44
46
  {config.versions && currentVersion && <NavbarVersion />}
@@ -3,6 +3,7 @@ import PageNavPrimitive from '@components/primitives/page-nav'
3
3
 
4
4
  /**
5
5
  * Component to display the previous and next page navigation buttons.
6
+ * Enhanced with subtle entrance animations and a modern card layout.
6
7
  */
7
8
  export function PageNav() {
8
9
  const { prevPage, nextPage } = usePageNav()
@@ -10,7 +11,7 @@ export function PageNav() {
10
11
  if (!prevPage && !nextPage) return null
11
12
 
12
13
  return (
13
- <PageNavPrimitive.PageNavRoot>
14
+ <PageNavPrimitive.PageNavRoot className="animate-in fade-in slide-in-from-bottom-4 duration-700">
14
15
  {prevPage ? (
15
16
  <PageNavPrimitive.PageNavLink to={prevPage.path} direction="prev">
16
17
  <PageNavPrimitive.PageNavLink.Title>
@@ -14,7 +14,10 @@ export function PoweredBy() {
14
14
  fill="currentColor"
15
15
  />
16
16
  <span className="text-[11px] font-medium text-text-muted group-hover:text-text-main transition-colors duration-300 tracking-wide">
17
- Powered by <strong className="font-bold text-text-main/80 group-hover:text-text-main">Boltdocs</strong>
17
+ Powered by{' '}
18
+ <strong className="font-bold text-text-main/80 group-hover:text-text-main">
19
+ Boltdocs
20
+ </strong>
18
21
  </span>
19
22
  </a>
20
23
  </div>
@@ -12,8 +12,17 @@ import {
12
12
  } from '@components/primitives/search-dialog'
13
13
  import Navbar from '@components/primitives/navbar'
14
14
  import { useNavigate } from 'react-router-dom'
15
+ import type { ComponentRoute } from '@client/types'
16
+ interface SearchResult {
17
+ id: string
18
+ title: string
19
+ path: string
20
+ bio: string
21
+ groupTitle?: string
22
+ isHeading?: boolean
23
+ }
15
24
 
16
- export function SearchDialog({ routes }: { routes: any[] }) {
25
+ export function SearchDialog({ routes }: { routes: ComponentRoute[] }) {
17
26
  const { isOpen, setIsOpen, query, setQuery, list } = useSearch(routes)
18
27
  const navigate = useNavigate()
19
28
 
@@ -58,10 +67,12 @@ export function SearchDialog({ routes }: { routes: any[] }) {
58
67
  <SearchDialogAutocomplete onSelectionChange={handleSelect}>
59
68
  <SearchDialogInput
60
69
  value={query}
61
- onChange={(e: any) => setQuery(e.target.value)}
70
+ onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
71
+ setQuery(e.target.value)
72
+ }
62
73
  />
63
- <SearchDialogList items={list}>
64
- {(item: any) => (
74
+ <SearchDialogList items={list as SearchResult[]}>
75
+ {(item: SearchResult) => (
65
76
  <SearchDialogItemRoot
66
77
  key={item.id}
67
78
  onPress={() => handleSelect(item.id)}
@@ -70,7 +81,7 @@ export function SearchDialog({ routes }: { routes: any[] }) {
70
81
  <SearchDialogItemIcon isHeading={item.isHeading} />
71
82
  <div className="flex flex-col justify-center gap-0.5">
72
83
  <SearchDialogItemTitle>{item.title}</SearchDialogItemTitle>
73
- <SearchDialogItemBio>{item.groupTitle}</SearchDialogItemBio>
84
+ <SearchDialogItemBio>{item.bio}</SearchDialogItemBio>
74
85
  </div>
75
86
  </SearchDialogItemRoot>
76
87
  )}
@@ -48,7 +48,8 @@ function CollapsibleSidebarGroup({
48
48
  >
49
49
  {group.routes.map((route: ComponentRoute) => {
50
50
  const isCurrent =
51
- activePath === (route.path.endsWith('/') ? route.path.slice(0, -1) : route.path)
51
+ activePath ===
52
+ (route.path.endsWith('/') ? route.path.slice(0, -1) : route.path)
52
53
  return (
53
54
  <SidebarPrimitive.SidebarLink
54
55
  key={route.path}
@@ -80,7 +81,8 @@ export function Sidebar({
80
81
  <SidebarPrimitive.SidebarGroup className="mb-6">
81
82
  {ungrouped.map((route) => {
82
83
  const isCurrent =
83
- activePath === (route.path.endsWith('/') ? route.path.slice(0, -1) : route.path)
84
+ activePath ===
85
+ (route.path.endsWith('/') ? route.path.slice(0, -1) : route.path)
84
86
  return (
85
87
  <SidebarPrimitive.SidebarLink
86
88
  key={route.path}
@@ -29,7 +29,7 @@ export function useI18n(): UseI18nReturn {
29
29
 
30
30
  const handleLocaleChange = (locale: string) => {
31
31
  if (!i18n || locale === currentLocale) return
32
-
32
+
33
33
  // Update store
34
34
  setLocale(locale)
35
35
 
@@ -87,7 +87,8 @@ export function useI18n(): UseI18nReturn {
87
87
  navigate(targetPath)
88
88
  }
89
89
 
90
- const currentLocaleConfig = config.i18n?.localeConfigs?.[currentLocale as string]
90
+ const currentLocaleConfig =
91
+ config.i18n?.localeConfigs?.[currentLocale as string]
91
92
  const currentLocaleLabel =
92
93
  currentLocaleConfig?.label ||
93
94
  config.i18n?.locales[currentLocale as string] ||
@@ -8,10 +8,11 @@ import { useRoutes } from './use-routes'
8
8
  */
9
9
  export function useLocalizedTo(to: RouterLinkProps['to']) {
10
10
  const config = useConfig()
11
- const { currentLocale: activeLocale, currentVersion: activeVersion } = useRoutes()
11
+ const { currentLocale: activeLocale, currentVersion: activeVersion } =
12
+ useRoutes()
12
13
 
13
14
  if (!config || typeof to !== 'string') return to
14
-
15
+
15
16
  // External or absolute links don't need localization
16
17
  if (to.startsWith('http') || to.startsWith('//')) return to
17
18
 
@@ -22,7 +23,7 @@ export function useLocalizedTo(to: RouterLinkProps['to']) {
22
23
 
23
24
  // 1. Identify the input intent
24
25
  const isDocLink = to.startsWith('/docs')
25
-
26
+
26
27
  // 3. Clean the 'to' path of ANY existing prefixes to avoid stacking
27
28
  const parts = to.split('/').filter(Boolean)
28
29
  let pIdx = 0
@@ -32,7 +33,7 @@ export function useLocalizedTo(to: RouterLinkProps['to']) {
32
33
 
33
34
  // Strip versions if present
34
35
  if (versions && parts.length > pIdx) {
35
- const vMatch = versions.versions.find(v => v.path === parts[pIdx])
36
+ const vMatch = versions.versions.find((v) => v.path === parts[pIdx])
36
37
  if (vMatch) pIdx++
37
38
  }
38
39
 
@@ -62,7 +63,7 @@ export function useLocalizedTo(to: RouterLinkProps['to']) {
62
63
  resultParts.push(...routeContent)
63
64
 
64
65
  const finalPath = `/${resultParts.join('/')}`
65
-
66
+
66
67
  // Cleanup trailing slashes unless it's just root
67
68
  if (finalPath.length > 1 && finalPath.endsWith('/')) {
68
69
  return finalPath.slice(0, -1)
@@ -1,18 +1,39 @@
1
+ import { useLocation } from 'react-router-dom'
1
2
  import { useRoutes } from './use-routes'
2
3
 
3
4
  /**
4
5
  * Hook to manage the previous and next button functionality for documentation pages.
6
+ * Intelligent: respects current locale, version, and tab to keep navigation logical.
5
7
  */
6
8
  export function usePageNav() {
7
- const { routes } = useRoutes()
8
- const currentPath = window.location.pathname
9
+ const { routes, currentRoute } = useRoutes()
10
+ const location = useLocation()
9
11
 
10
- const currentIndex = routes.findIndex((r) => r.path === currentPath)
11
- const currentRoute = routes[currentIndex]
12
+ if (!currentRoute) {
13
+ return {
14
+ prevPage: null,
15
+ nextPage: null,
16
+ currentRoute: null,
17
+ }
18
+ }
19
+
20
+ const activeTabId = currentRoute.tab?.toLowerCase()
21
+
22
+ // Subset of routes that match the current context (locale and version are already filtered by useRoutes)
23
+ // We further filter by tab to keep the user in the same logical section
24
+ const contextRoutes = activeTabId
25
+ ? routes.filter((r) => r.tab?.toLowerCase() === activeTabId)
26
+ : routes.filter((r) => !r.tab)
27
+
28
+ const currentIndex = contextRoutes.findIndex(
29
+ (r) => r.path === location.pathname,
30
+ )
12
31
 
13
- const prevPage = currentIndex > 0 ? routes[currentIndex - 1] : null
32
+ const prevPage = currentIndex > 0 ? contextRoutes[currentIndex - 1] : null
14
33
  const nextPage =
15
- currentIndex < routes.length - 1 ? routes[currentIndex + 1] : null
34
+ currentIndex !== -1 && currentIndex < contextRoutes.length - 1
35
+ ? contextRoutes[currentIndex + 1]
36
+ : null
16
37
 
17
38
  return {
18
39
  prevPage,
@@ -71,7 +71,8 @@ export function useRoutes() {
71
71
  })
72
72
 
73
73
  // Labels and lists for UI convenience
74
- const currentLocaleConfig = config.i18n?.localeConfigs?.[currentLocale as string]
74
+ const currentLocaleConfig =
75
+ config.i18n?.localeConfigs?.[currentLocale as string]
75
76
  const currentLocaleLabel =
76
77
  currentLocaleConfig?.label ||
77
78
  config.i18n?.locales[currentLocale as string] ||
@@ -1,77 +1,98 @@
1
- import { useState, useMemo } from 'react'
1
+ import { useState, useMemo, useEffect } from 'react'
2
+ import { Index } from 'flexsearch'
2
3
  import { useRoutes } from './use-routes'
3
4
  import type { ComponentRoute } from '@client/types'
5
+ // @ts-ignore
6
+ import searchData from 'virtual:boltdocs-search'
7
+
8
+ interface SearchDataItem {
9
+ id: string
10
+ title: string
11
+ content: string
12
+ url: string
13
+ display: string
14
+ locale?: string
15
+ version?: string
16
+ }
4
17
 
5
18
  export function useSearch(routes: ComponentRoute[]) {
6
19
  const { currentLocale, currentVersion } = useRoutes()
7
20
  const [isOpen, setIsOpen] = useState(false)
8
21
  const [query, setQuery] = useState('')
22
+ const [index, setIndex] = useState<Index | null>(null)
9
23
 
10
- const list = useMemo(() => {
11
- // 0. Filter routes by active context
12
- const activeRoutes = routes.filter((r) => {
13
- const localeMatch = !currentLocale || r.locale === currentLocale
14
- const versionMatch = !currentVersion || r.version === currentVersion
15
- return localeMatch && versionMatch
24
+ // Initialize FlexSearch index once
25
+ useEffect(() => {
26
+ if (!isOpen || index) return
27
+
28
+ const newIndex = new Index({
29
+ preset: 'match',
30
+ tokenize: 'full',
31
+ resolution: 9,
32
+ cache: true,
16
33
  })
17
34
 
18
- if (!query) {
19
- return activeRoutes.slice(0, 10).map((r) => ({
20
- id: r.path,
21
- title: r.title,
22
- path: r.path,
23
- bio: r.description || '',
24
- groupTitle: r.groupTitle,
25
- }))
35
+ // Index all documents
36
+ for (const doc of searchData as SearchDataItem[]) {
37
+ newIndex.add(doc.id, `${doc.title} ${doc.content}`)
26
38
  }
27
39
 
28
- const results: {
29
- id: string
30
- title: string | undefined
31
- path: string
32
- bio: string
33
- groupTitle: string | undefined
34
- isHeading?: boolean
35
- }[] = []
36
- const lowerQuery = query.toLowerCase()
37
-
38
- for (const route of activeRoutes) {
39
- if (route.title?.toLowerCase().includes(lowerQuery)) {
40
- results.push({
41
- id: route.path,
42
- title: route.title,
43
- path: route.path,
44
- bio: route.description || '',
45
- groupTitle: route.groupTitle,
40
+ setIndex(newIndex)
41
+ }, [isOpen, index])
42
+
43
+ const list = useMemo(() => {
44
+ if (!query) {
45
+ // Default results: just active routes
46
+ return routes
47
+ .filter((r) => {
48
+ const localeMatch = !currentLocale || r.locale === currentLocale
49
+ const versionMatch = !currentVersion || r.version === currentVersion
50
+ return localeMatch && versionMatch
46
51
  })
47
- }
48
-
49
- if (route.headings) {
50
- for (const heading of route.headings) {
51
- if (heading.text.toLowerCase().includes(lowerQuery)) {
52
- results.push({
53
- id: `${route.path}#${heading.id}`,
54
- title: heading.text,
55
- path: `${route.path}#${heading.id}`,
56
- bio: `Heading in ${route.title}`,
57
- groupTitle: route.title,
58
- isHeading: true,
59
- })
60
- }
61
- }
62
- }
52
+ .slice(0, 10)
53
+ .map((r) => ({
54
+ id: r.path,
55
+ title: r.title,
56
+ path: r.path,
57
+ bio: r.description || '',
58
+ groupTitle: r.groupTitle,
59
+ }))
63
60
  }
64
61
 
65
- // Deduplicate by path
66
- const seen = new Set()
67
- return results
68
- .filter((r) => {
69
- if (seen.has(r.path)) return false
70
- seen.add(r.path)
71
- return true
62
+ if (!index) return []
63
+
64
+ const searchResults = index.search(query, {
65
+ limit: 20,
66
+ suggest: true,
67
+ })
68
+
69
+ const results: any[] = []
70
+ const seen = new Set<string>()
71
+
72
+ for (const id of searchResults) {
73
+ const doc = (searchData as SearchDataItem[]).find((d) => d.id === id)
74
+ if (!doc) continue
75
+
76
+ // Filter by locale and version
77
+ const localeMatch = !currentLocale || doc.locale === currentLocale
78
+ const versionMatch = !currentVersion || doc.version === currentVersion
79
+ if (!localeMatch || !versionMatch) continue
80
+
81
+ if (seen.has(doc.url)) continue
82
+ seen.add(doc.url)
83
+
84
+ results.push({
85
+ id: doc.url,
86
+ title: doc.title,
87
+ path: doc.url,
88
+ bio: doc.display,
89
+ groupTitle: doc.display.split(' > ')[0],
90
+ isHeading: doc.url.includes('#'),
72
91
  })
73
- .slice(0, 10)
74
- }, [routes, query, currentLocale, currentVersion])
92
+ }
93
+
94
+ return results.slice(0, 10)
95
+ }, [query, index, currentLocale, currentVersion, routes])
75
96
 
76
97
  return {
77
98
  isOpen,
@@ -81,7 +102,8 @@ export function useSearch(routes: ComponentRoute[]) {
81
102
  list,
82
103
  input: {
83
104
  value: query,
84
- onChange: (e: React.ChangeEvent<HTMLInputElement>) => setQuery(e.target.value),
105
+ onChange: (e: React.ChangeEvent<HTMLInputElement>) =>
106
+ setQuery(e.target.value),
85
107
  },
86
108
  }
87
109
  }
@@ -7,7 +7,8 @@ export function useSidebar(routes: ComponentRoute[]) {
7
7
  const location = useLocation()
8
8
 
9
9
  // Find active route and tab
10
- const normalize = (p: string) => (p.endsWith('/') && p.length > 1 ? p.slice(0, -1) : p)
10
+ const normalize = (p: string) =>
11
+ p.endsWith('/') && p.length > 1 ? p.slice(0, -1) : p
11
12
  const currentPath = normalize(location.pathname)
12
13
 
13
14
  const activeRoute = routes.find((r) => normalize(r.path) === currentPath)
@@ -5,7 +5,7 @@ interface BoltdocsState {
5
5
  currentLocale: string | undefined
6
6
  currentVersion: string | undefined
7
7
  hasHydrated: boolean
8
-
8
+
9
9
  // Actions
10
10
  setLocale: (locale: string | undefined) => void
11
11
  setVersion: (version: string | undefined) => void
@@ -22,9 +22,10 @@ export const useBoltdocsStore = create<BoltdocsState>()(
22
22
  currentLocale: undefined,
23
23
  currentVersion: undefined,
24
24
  hasHydrated: false,
25
-
25
+
26
26
  setLocale: (locale: string | undefined) => set({ currentLocale: locale }),
27
- setVersion: (version: string | undefined) => set({ currentVersion: version }),
27
+ setVersion: (version: string | undefined) =>
28
+ set({ currentVersion: version }),
28
29
  setHasHydrated: (val: boolean) => set({ hasHydrated: val }),
29
30
  }),
30
31
  {
@@ -38,6 +39,6 @@ export const useBoltdocsStore = create<BoltdocsState>()(
38
39
  onRehydrateStorage: () => (state?: BoltdocsState) => {
39
40
  state?.setHasHydrated(true)
40
41
  },
41
- }
42
- )
42
+ },
43
+ ),
43
44
  )
@@ -67,6 +67,35 @@
67
67
  --spacing-sidebar: 16rem;
68
68
  --spacing-toc: 14rem;
69
69
  --spacing-content-max: 48rem;
70
+
71
+ @keyframes pulse {
72
+ 0%,
73
+ 100% {
74
+ opacity: 1;
75
+ }
76
+ 50% {
77
+ opacity: 0.5;
78
+ }
79
+ }
80
+
81
+ @keyframes fade-in {
82
+ from {
83
+ opacity: 0;
84
+ transform: translateY(10px);
85
+ }
86
+ to {
87
+ opacity: 1;
88
+ transform: translateY(0);
89
+ }
90
+ }
91
+ }
92
+
93
+ .animate-pulse {
94
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
95
+ }
96
+
97
+ .animate-fade-in {
98
+ animation: fade-in 0.5s ease-out forwards;
70
99
  }
71
100
 
72
101
  :root[data-theme="dark"],