boltdocs 2.6.2 → 2.7.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 (177) hide show
  1. package/bin/boltdocs.js +0 -1
  2. package/dist/cache-CQKlT4fI.mjs +6 -0
  3. package/dist/cache-DorPMFgW.cjs +6 -0
  4. package/dist/cards-BLoSiRuL.d.ts +30 -0
  5. package/dist/cards-CQn9mXZS.d.cts +30 -0
  6. package/dist/chunk-Ds5LZdWN.cjs +6 -0
  7. package/dist/client/index.cjs +1 -1
  8. package/dist/client/index.d.cts +167 -1338
  9. package/dist/client/index.d.ts +166 -1337
  10. package/dist/client/index.js +1 -1
  11. package/dist/{package-CFP44vfn.cjs → client/mdx.cjs} +1 -1
  12. package/dist/client/mdx.d.cts +128 -0
  13. package/dist/client/mdx.d.ts +129 -0
  14. package/dist/client/mdx.js +6 -0
  15. package/dist/client/primitives.cjs +6 -0
  16. package/dist/client/primitives.d.cts +818 -0
  17. package/dist/client/primitives.d.ts +818 -0
  18. package/dist/client/primitives.js +6 -0
  19. package/dist/client/theme/neutral.css +74 -361
  20. package/dist/client/theme/reset.css +189 -0
  21. package/dist/docs-layout-BlDhcQRv.cjs +6 -0
  22. package/dist/docs-layout-BvAOWEJw.js +6 -0
  23. package/dist/doctor-BQiQhCTl.cjs +6 -0
  24. package/dist/doctor-COpf35L2.cjs +20 -0
  25. package/dist/doctor-Dh1XP7Pz.mjs +20 -0
  26. package/dist/generator-DGW6pkCC.cjs +22 -0
  27. package/dist/generator-Dv3wEmhZ.mjs +22 -0
  28. package/dist/icons-dev-CrQLjoQp.js +6 -0
  29. package/dist/icons-dev-rzdz6Lf3.cjs +6 -0
  30. package/dist/image-BkIfa9oo.js +6 -0
  31. package/dist/image-DIGjCPe6.cjs +6 -0
  32. package/dist/mdx-K0WYBAJ3.js +7 -0
  33. package/dist/mdx-hpErbRUe.cjs +7 -0
  34. package/dist/meta-loader-0gJ4PtBC.cjs +6 -0
  35. package/dist/meta-loader-9IpAHWDS.mjs +6 -0
  36. package/dist/node/cli-entry.cjs +1 -2
  37. package/dist/node/cli-entry.mjs +1 -2
  38. package/dist/node/index.cjs +1 -1
  39. package/dist/node/index.d.cts +55 -11
  40. package/dist/node/index.d.mts +55 -12
  41. package/dist/node/index.mjs +1 -1
  42. package/dist/node/routes/worker.cjs +6 -0
  43. package/dist/node/routes/worker.d.cts +2 -0
  44. package/dist/node/routes/worker.d.mts +2 -0
  45. package/dist/node/routes/worker.mjs +6 -0
  46. package/dist/node-C2nWXElP.mjs +112 -0
  47. package/dist/node-CinkUtxV.cjs +112 -0
  48. package/dist/package-BMYLDBBP.cjs +6 -0
  49. package/dist/{package-Bqbn1AYK.mjs → package-HegMOTL_.mjs} +1 -1
  50. package/dist/parser-Bh11BsdA.cjs +6 -0
  51. package/dist/parser-D8eQvE7N.mjs +6 -0
  52. package/dist/parser-DYRzXWmA.cjs +6 -0
  53. package/dist/routes-CHf76Ye4.cjs +6 -0
  54. package/dist/routes-CMUZGI6T.mjs +6 -0
  55. package/dist/routes-Co1mRM58.cjs +6 -0
  56. package/dist/search-dialog-BACuzoVX.cjs +6 -0
  57. package/dist/search-dialog-BKagVT17.js +6 -0
  58. package/dist/search-dialog-C8w12eUx.js +6 -0
  59. package/dist/search-dialog-CGyrozZE.cjs +6 -0
  60. package/dist/search-dialog-D26rUnJ_.cjs +6 -0
  61. package/dist/sidebar-DKvg6KOc.d.cts +491 -0
  62. package/dist/sidebar-Dr1TiRIy.d.ts +491 -0
  63. package/dist/utils-BxNAXhZZ.mjs +7 -0
  64. package/dist/utils-Clzu7jvb.cjs +7 -0
  65. package/dist/worker-pool-Bd8Y9KDv.mjs +6 -0
  66. package/dist/worker-pool-BwU8ckrg.cjs +6 -0
  67. package/package.json +27 -8
  68. package/src/client/app/doc-page.tsx +9 -5
  69. package/src/client/app/docs-layout.tsx +17 -3
  70. package/src/client/app/head.tsx +122 -0
  71. package/src/client/app/helmet-compat.tsx +36 -0
  72. package/src/client/app/mdx-component.tsx +5 -52
  73. package/src/client/app/mdx-components-context.tsx +32 -8
  74. package/src/client/app/routes-context.tsx +2 -2
  75. package/src/client/app/scroll-handler.tsx +1 -1
  76. package/src/client/app/theme-context.tsx +5 -5
  77. package/src/client/app/ui-context.tsx +42 -0
  78. package/src/client/components/docs-layout-default.tsx +85 -0
  79. package/src/client/components/icons-dev.tsx +38 -15
  80. package/src/client/components/mdx/callout.tsx +97 -0
  81. package/src/client/components/mdx/card.tsx +73 -98
  82. package/src/client/components/mdx/cards.tsx +27 -0
  83. package/src/client/components/mdx/code-block.tsx +37 -17
  84. package/src/client/components/mdx/field.tsx +24 -56
  85. package/src/client/components/mdx/image.tsx +36 -15
  86. package/src/client/components/mdx/index.ts +19 -53
  87. package/src/client/components/mdx/table.tsx +46 -148
  88. package/src/client/components/mdx/typographics.tsx +120 -0
  89. package/src/client/components/mdx/{hooks/use-code-block.ts → use-code-block.ts} +5 -7
  90. package/src/client/components/primitives/breadcrumbs.tsx +5 -24
  91. package/src/client/components/primitives/button.tsx +3 -142
  92. package/src/client/components/primitives/code-block.tsx +104 -97
  93. package/src/client/components/{docs-layout.tsx → primitives/docs-layout.tsx} +15 -24
  94. package/src/client/components/primitives/error-boundary.tsx +107 -0
  95. package/src/client/components/primitives/heading.tsx +128 -0
  96. package/src/client/components/primitives/helpers/observer.ts +62 -32
  97. package/src/client/components/primitives/image.tsx +26 -0
  98. package/src/client/components/primitives/link.tsx +50 -52
  99. package/src/client/components/primitives/menu.tsx +25 -49
  100. package/src/client/components/primitives/navbar.tsx +234 -59
  101. package/src/client/components/primitives/on-this-page.tsx +169 -40
  102. package/src/client/components/primitives/page-nav.tsx +11 -39
  103. package/src/client/components/primitives/popover.tsx +12 -30
  104. package/src/client/components/primitives/search-dialog.tsx +77 -71
  105. package/src/client/components/primitives/sidebar.tsx +312 -119
  106. package/src/client/components/primitives/skeleton.tsx +1 -1
  107. package/src/client/components/primitives/tabs.tsx +5 -16
  108. package/src/client/components/primitives/tooltip.tsx +1 -1
  109. package/src/client/components/ui-base/banner.tsx +66 -0
  110. package/src/client/components/ui-base/breadcrumbs.tsx +26 -20
  111. package/src/client/components/ui-base/copy-markdown.tsx +43 -35
  112. package/src/client/components/ui-base/error-boundary.tsx +9 -46
  113. package/src/client/components/ui-base/github-stars.tsx +5 -3
  114. package/src/client/components/ui-base/index.ts +3 -3
  115. package/src/client/components/ui-base/last-updated.tsx +27 -0
  116. package/src/client/components/ui-base/navbar.tsx +183 -89
  117. package/src/client/components/ui-base/not-found.tsx +11 -9
  118. package/src/client/components/ui-base/on-this-page.tsx +8 -104
  119. package/src/client/components/ui-base/page-nav.tsx +23 -9
  120. package/src/client/components/ui-base/search-dialog.tsx +111 -36
  121. package/src/client/components/ui-base/search-highlight.tsx +10 -0
  122. package/src/client/components/ui-base/sidebar.tsx +77 -154
  123. package/src/client/components/ui-base/tabs.tsx +20 -7
  124. package/src/client/components/ui-base/theme-toggle.tsx +88 -10
  125. package/src/client/components/ui-base/version-i18n.tsx +80 -0
  126. package/src/client/hooks/index.ts +2 -1
  127. package/src/client/hooks/use-analytics.ts +272 -0
  128. package/src/client/hooks/use-i18n.ts +116 -50
  129. package/src/client/hooks/use-localized-to.ts +70 -27
  130. package/src/client/hooks/use-navbar.ts +69 -39
  131. package/src/client/hooks/use-page-nav.ts +28 -25
  132. package/src/client/hooks/use-routes.ts +63 -80
  133. package/src/client/hooks/use-search-highlight.ts +185 -0
  134. package/src/client/hooks/use-search.ts +12 -3
  135. package/src/client/hooks/use-sidebar.ts +183 -80
  136. package/src/client/hooks/use-tabs.ts +3 -4
  137. package/src/client/hooks/use-version.ts +44 -29
  138. package/src/client/index.ts +13 -87
  139. package/src/client/mdx.ts +2 -0
  140. package/src/client/primitives.ts +19 -0
  141. package/src/client/ssg/boltdocs-shell.tsx +68 -79
  142. package/src/client/ssg/create-routes.tsx +268 -72
  143. package/src/client/ssg/mdx-page.tsx +2 -1
  144. package/src/client/store/boltdocs-context.tsx +72 -20
  145. package/src/client/theme/neutral.css +74 -361
  146. package/src/client/theme/reset.css +189 -0
  147. package/src/client/types.ts +10 -2
  148. package/src/client/utils/path.ts +9 -0
  149. package/src/client/utils/react-to-text.ts +24 -24
  150. package/src/client/virtual.d.ts +1 -1
  151. package/src/shared/types.ts +82 -22
  152. package/dist/node-Bogvkxao.mjs +0 -101
  153. package/dist/node-CXaog6St.cjs +0 -101
  154. package/dist/search-dialog-CV3eJzMm.cjs +0 -6
  155. package/dist/search-dialog-DNTomKgu.js +0 -6
  156. package/dist/use-search-CS3gH19M.js +0 -6
  157. package/dist/use-search-DBpJZQuw.cjs +0 -6
  158. package/src/client/components/mdx/admonition.tsx +0 -91
  159. package/src/client/components/mdx/badge.tsx +0 -41
  160. package/src/client/components/mdx/button.tsx +0 -35
  161. package/src/client/components/mdx/component-preview.tsx +0 -37
  162. package/src/client/components/mdx/component-props.tsx +0 -83
  163. package/src/client/components/mdx/file-tree.tsx +0 -325
  164. package/src/client/components/mdx/hooks/use-component-preview.ts +0 -16
  165. package/src/client/components/mdx/hooks/useTable.ts +0 -74
  166. package/src/client/components/mdx/hooks/useTabs.ts +0 -68
  167. package/src/client/components/mdx/link.tsx +0 -38
  168. package/src/client/components/mdx/list.tsx +0 -192
  169. package/src/client/components/mdx/tabs.tsx +0 -135
  170. package/src/client/components/mdx/video.tsx +0 -68
  171. package/src/client/components/primitives/index.ts +0 -19
  172. package/src/client/components/primitives/navigation-menu.tsx +0 -114
  173. package/src/client/components/ui-base/head.tsx +0 -83
  174. package/src/client/components/ui-base/loading.tsx +0 -57
  175. package/src/client/components/ui-base/powered-by.tsx +0 -25
  176. package/src/client/hooks/use-onthispage.ts +0 -23
  177. package/src/client/utils/use-on-change.ts +0 -15
@@ -1,3 +1,4 @@
1
+ import { useMemo } from 'react'
1
2
  import { useLocation } from 'react-router-dom'
2
3
  import { useConfig } from '../app/config-context'
3
4
  import { useTheme } from '../app/theme-context'
@@ -18,53 +19,82 @@ export function useNavbar() {
18
19
  const githubRepo = themeConfig.githubRepo
19
20
 
20
21
  // Transform links to the new NavbarLink structure
21
- const links: NavbarLink[] = rawLinks.map((item: any) => {
22
- const href = (item.href || item.to || item.link || '') as string
23
-
24
- // Robust active state calculation
25
- const getIsActive = (h: string) => {
26
- const activePath = location.pathname
27
- if (activePath === h) return true
28
- if (!h || h === '/') return activePath === '/'
29
-
30
- const cleanPathParts = (p: string) => {
31
- const parts = p.split('/').filter(Boolean)
32
- let i = 0
33
- // Skip locale
34
- if (config.i18n?.locales && parts[i] && config.i18n.locales[parts[i]]) {
35
- i++
36
- }
37
- // Skip version
38
- if (config.versions?.versions && parts[i]) {
39
- if (config.versions.versions.some((v) => v.path === parts[i])) {
22
+ const links: NavbarLink[] = useMemo(() => {
23
+ return rawLinks.map((item: any) => {
24
+ const href = (item.href || item.to || item.link || '') as string
25
+
26
+ // Robust active state calculation
27
+ const getIsActive = (h: string) => {
28
+ const activePath = location.pathname
29
+ if (activePath === h) return true
30
+ if (!h || h === '/') return activePath === '/'
31
+
32
+ const cleanPathParts = (p: string) => {
33
+ const parts = p.split('/').filter(Boolean)
34
+ let i = 0
35
+ // Skip locale
36
+ if (
37
+ config.i18n?.locales &&
38
+ parts[i] &&
39
+ config.i18n.locales[parts[i]]
40
+ ) {
40
41
  i++
41
42
  }
43
+ // Skip version
44
+ if (config.versions?.versions && parts[i]) {
45
+ if (config.versions.versions.some((v) => v.path === parts[i])) {
46
+ i++
47
+ }
48
+ }
49
+ return parts.slice(i)
42
50
  }
43
- return parts.slice(i)
44
- }
45
51
 
46
- const hParts = cleanPathParts(h)
47
- const pParts = cleanPathParts(activePath)
52
+ const hParts = cleanPathParts(h)
53
+ const pParts = cleanPathParts(activePath)
48
54
 
49
- if (hParts.length === 0) return pParts.length === 0
55
+ if (hParts.length === 0) return pParts.length === 0
50
56
 
51
- // Must match at least as many parts as the candidate link
52
- if (pParts.length < hParts.length) return false
57
+ // Must match at least as many parts as the candidate link
58
+ if (pParts.length < hParts.length) return false
53
59
 
54
- // Every part of hParts must match pParts at the same position
55
- return hParts.every((part, i) => pParts[i] === part)
56
- }
60
+ // Every part of hParts must match pParts at the same position
61
+ return hParts.every((part, i) => pParts[i] === part)
62
+ }
57
63
 
58
- return {
59
- label: getTranslated(item.label || item.text, currentLocale),
60
- href,
61
- active: getIsActive(href),
62
- to:
63
- href.startsWith('http') || href.startsWith('//')
64
- ? 'external'
65
- : undefined,
66
- }
67
- })
64
+ // Process nested items recursively
65
+ const processItems = (items?: any[]): NavbarLink[] => {
66
+ if (!items || items.length === 0) return undefined as any
67
+ return items.map((subItem: any) => {
68
+ const subHref = (subItem.href ||
69
+ subItem.to ||
70
+ subItem.link ||
71
+ '') as string
72
+ return {
73
+ label: getTranslated(subItem.label || subItem.text, currentLocale),
74
+ href: subHref,
75
+ active: getIsActive(subHref),
76
+ to:
77
+ subHref.startsWith('http') || subHref.startsWith('//')
78
+ ? 'external'
79
+ : undefined,
80
+ }
81
+ })
82
+ }
83
+
84
+ const linkItems = processItems(item.items)
85
+
86
+ return {
87
+ label: getTranslated(item.label || item.text, currentLocale),
88
+ href,
89
+ active: getIsActive(href),
90
+ to:
91
+ href.startsWith('http') || href.startsWith('//')
92
+ ? 'external'
93
+ : undefined,
94
+ items: linkItems,
95
+ }
96
+ })
97
+ }, [rawLinks, location.pathname, currentLocale, config])
68
98
 
69
99
  const logo = themeConfig.logo
70
100
  // Use resolvedTheme so 'system' correctly maps to 'dark' or 'light'
@@ -1,3 +1,4 @@
1
+ import { useMemo } from 'react'
1
2
  import { useLocation } from 'react-router-dom'
2
3
  import { useRoutes } from './use-routes'
3
4
 
@@ -9,35 +10,37 @@ export function usePageNav() {
9
10
  const { routes, currentRoute } = useRoutes()
10
11
  const location = useLocation()
11
12
 
12
- if (!currentRoute) {
13
- return {
14
- prevPage: null,
15
- nextPage: null,
16
- currentRoute: null,
13
+ return useMemo(() => {
14
+ if (!currentRoute) {
15
+ return {
16
+ prevPage: null,
17
+ nextPage: null,
18
+ currentRoute: null,
19
+ }
17
20
  }
18
- }
19
21
 
20
- const activeTabId = currentRoute.tab?.toLowerCase()
22
+ const activeTabId = currentRoute.tab?.toLowerCase()
21
23
 
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)
24
+ // Subset of routes that match the current context (locale and version are already filtered by useRoutes)
25
+ // We further filter by tab to keep the user in the same logical section
26
+ const contextRoutes = activeTabId
27
+ ? routes.filter((r) => r.tab?.toLowerCase() === activeTabId)
28
+ : routes.filter((r) => !r.tab)
27
29
 
28
- const currentIndex = contextRoutes.findIndex(
29
- (r) => r.path === location.pathname,
30
- )
30
+ const currentIndex = contextRoutes.findIndex(
31
+ (r) => r.path === location.pathname,
32
+ )
31
33
 
32
- const prevPage = currentIndex > 0 ? contextRoutes[currentIndex - 1] : null
33
- const nextPage =
34
- currentIndex !== -1 && currentIndex < contextRoutes.length - 1
35
- ? contextRoutes[currentIndex + 1]
36
- : null
34
+ const prevPage = currentIndex > 0 ? contextRoutes[currentIndex - 1] : null
35
+ const nextPage =
36
+ currentIndex !== -1 && currentIndex < contextRoutes.length - 1
37
+ ? contextRoutes[currentIndex + 1]
38
+ : null
37
39
 
38
- return {
39
- prevPage,
40
- nextPage,
41
- currentRoute,
42
- }
40
+ return {
41
+ prevPage,
42
+ nextPage,
43
+ currentRoute,
44
+ }
45
+ }, [routes, currentRoute, location.pathname])
43
46
  }
@@ -1,7 +1,9 @@
1
+ import { useMemo } from 'react'
1
2
  import { useLocation } from 'react-router-dom'
2
3
  import { useConfig } from '../app/config-context'
3
4
  import { useRoutesContext } from '../app/routes-context'
4
5
  import { useBoltdocsContext } from '../store/boltdocs-context'
6
+ import { normalizePath } from '../utils/path'
5
7
 
6
8
  /**
7
9
  * Hook to access the framework's routing state.
@@ -20,106 +22,87 @@ export function useRoutes() {
20
22
  currentVersion: currentVersionStore,
21
23
  } = useBoltdocsContext()
22
24
 
23
- const normalize = (p: string) =>
24
- p.endsWith('/') && p.length > 1 ? p.slice(0, -1) : p
25
- const currentPath = normalize(location.pathname)
25
+ const currentPath = normalizePath(location.pathname)
26
26
 
27
- // Find the current route matching the pathname
27
+ // Find the current active route matching the pathname
28
28
  const currentRoute = allRoutes?.find?.(
29
- (r) => normalize(r.path) === currentPath,
29
+ (r) => normalizePath(r.path) === currentPath,
30
30
  )
31
31
 
32
- // Derive current locale and version
33
- // Priority: URL (currentRoute) > Zustand Store (Persistence) > Config Default
32
+ // 2. STRICT SOURCE OF TRUTH:
33
+ // Derive the active states exclusively from the hydrated Context Store.
34
+ // This ensures that user preference (LocalStorage) takes precedence over ambiguous URL fallbacks.
34
35
  const currentLocale = config.i18n
35
- ? currentRoute?.locale ||
36
- (hasHydrated ? currentLocaleStore : undefined) ||
37
- config.i18n.defaultLocale
36
+ ? currentLocaleStore || config.i18n.defaultLocale
38
37
  : undefined
39
38
 
40
39
  const currentVersion = config.versions
41
- ? currentRoute?.version ||
42
- (hasHydrated ? currentVersionStore : undefined) ||
43
- config.versions.defaultVersion
40
+ ? currentVersionStore || config.versions.defaultVersion
44
41
  : undefined
45
42
 
46
43
  // Filter routes to those matching the current version and locale
47
- const routes = allRoutes?.filter?.((r) => {
48
- const localeMatch = config.i18n
49
- ? (r.locale || config.i18n.defaultLocale) === currentLocale
50
- : true
51
- const versionMatch = config.versions
52
- ? (r.version || config.versions.defaultVersion) === currentVersion
53
- : true
54
-
55
- if (!(localeMatch && versionMatch)) return false
56
-
57
- // Resolve duplicate paths (aliases) like /docs vs /docs/en
58
- // We prefer the version that matches the current route's prefix style
59
- const i18n = config.i18n
60
- if (i18n) {
61
- const isCurrentRoutePrefixed = !!currentRoute?.locale
62
- const isRoutePrefixed = !!r.locale
63
-
64
- const hasAlternate = allRoutes?.some?.(
65
- (alt) =>
66
- alt !== r &&
67
- alt.filePath === r.filePath &&
68
- alt.version === r.version &&
69
- (alt.locale || i18n.defaultLocale) ===
70
- (r.locale || i18n.defaultLocale),
71
- )
72
-
73
- if (hasAlternate && isCurrentRoutePrefixed !== isRoutePrefixed) {
74
- return false
75
- }
44
+ const routes = useMemo(() => {
45
+ if (!allRoutes) return []
46
+
47
+ // Pre-calculate alternate presence using a Map of maps or a composite key
48
+ // Key: filePath | (locale || defaultLocale) | (version || defaultVersion)
49
+ const alternateCounts = new Map<string, number>()
50
+ const defaultLocale = config.i18n?.defaultLocale || ''
51
+ const defaultVersion = config.versions?.defaultVersion || ''
52
+
53
+ for (const r of allRoutes) {
54
+ const locale = r.locale || defaultLocale
55
+ const version = r.version || defaultVersion
56
+ const key = `${r.filePath}::${locale}::${version}`
57
+ alternateCounts.set(key, (alternateCounts.get(key) || 0) + 1)
76
58
  }
77
59
 
78
- return true
79
- })
80
-
81
- // Labels and lists for UI convenience
82
- const currentLocaleConfig =
83
- config.i18n?.localeConfigs?.[currentLocale as string]
84
- const currentLocaleLabel =
85
- currentLocaleConfig?.label ||
86
- config.i18n?.locales[currentLocale as string] ||
87
- currentLocale
88
-
89
- const currentVersionConfig = config.versions?.versions?.find?.(
90
- (v) => v.path === currentVersion,
91
- )
92
- const currentVersionLabel = currentVersionConfig?.label || currentVersion
93
-
94
- const availableLocales = config.i18n
95
- ? Object.entries(config.i18n.locales).map(([key, defaultLabel]) => {
96
- const localeConfig = config.i18n?.localeConfigs?.[key]
97
- return {
98
- key: key as import('../../shared/types').BoltdocsLocale,
99
- label: localeConfig?.label || defaultLabel,
100
- isCurrent: key === currentLocale,
60
+ return allRoutes.filter((r) => {
61
+ const localeMatch = config.i18n
62
+ ? (r.locale || config.i18n.defaultLocale) === currentLocale
63
+ : true
64
+ const versionMatch = config.versions
65
+ ? (r.version || config.versions.defaultVersion) === currentVersion
66
+ : true
67
+
68
+ if (!(localeMatch && versionMatch)) return false
69
+
70
+ // Resolve duplicate paths (aliases) like /docs vs /docs/en
71
+ // 3. Resolve duplicate route aliases (e.g., /docs/page vs /docs/latest/page or /docs/es/page)
72
+ // If duplicates exist, we only show the style (prefixed or unprefixed) that matches the user's current page style.
73
+ const isCurrentLocalePrefixed = !!currentRoute?.locale
74
+ const isCurrentVersionPrefixed = !!currentRoute?.version
75
+
76
+ const isRouteLocalePrefixed = !!r.locale
77
+ const isRouteVersionPrefixed = !!r.version
78
+
79
+ const locale = r.locale || defaultLocale
80
+ const version = r.version || defaultVersion
81
+ const key = `${r.filePath}::${locale}::${version}`
82
+ const hasAlternate = (alternateCounts.get(key) || 0) > 1
83
+
84
+ if (hasAlternate) {
85
+ // Style mismatch checks
86
+ const localeMismatch =
87
+ config.i18n && isCurrentLocalePrefixed !== isRouteLocalePrefixed
88
+ const versionMismatch =
89
+ config.versions && isCurrentVersionPrefixed !== isRouteVersionPrefixed
90
+
91
+ if (localeMismatch || versionMismatch) {
92
+ return false
101
93
  }
102
- })
103
- : []
94
+ }
104
95
 
105
- const availableVersions = config.versions
106
- ? config.versions.versions.map((v) => ({
107
- key: v.path as import('../../shared/types').BoltdocsVersion,
108
- label: v.label,
109
- isCurrent: v.path === currentVersion,
110
- }))
111
- : []
96
+ return true
97
+ })
98
+ }, [allRoutes, config, currentLocale, currentVersion, currentRoute])
112
99
 
113
100
  return {
114
101
  routes,
115
102
  allRoutes,
116
103
  currentRoute,
117
104
  currentLocale: currentLocale as import('../../shared/types').BoltdocsLocale,
118
- currentLocaleLabel,
119
- availableLocales,
120
- currentVersion: currentVersion as import('../../shared/types').BoltdocsVersion,
121
- currentVersionLabel,
122
- availableVersions,
123
- config,
105
+ currentVersion:
106
+ currentVersion as import('../../shared/types').BoltdocsVersion,
124
107
  }
125
108
  }
@@ -0,0 +1,185 @@
1
+ import { useEffect } from 'react'
2
+ import { useLocation } from './use-location'
3
+
4
+ /**
5
+ * Hook to highlight search terms based on the 'hl' query parameter.
6
+ */
7
+ export function useSearchHighlight(
8
+ containerSelector: string = '.boltdocs-page',
9
+ ) {
10
+ const { search } = useLocation()
11
+ const query = new URLSearchParams(search).get('hl')
12
+
13
+ useEffect(() => {
14
+ if (!query) {
15
+ clearHighlights(containerSelector)
16
+ return
17
+ }
18
+
19
+ const container = document.querySelector(containerSelector)
20
+ if (!container) return
21
+
22
+ let rafId: number
23
+
24
+ // Observe changes to the content (e.g. navigation or lazy loading)
25
+ const observer = new MutationObserver((mutations) => {
26
+ const hasExternalChanges = mutations.some((m) => {
27
+ const addedNodes = Array.from(m.addedNodes)
28
+ const removedNodes = Array.from(m.removedNodes)
29
+
30
+ return (
31
+ addedNodes.some(
32
+ (n) =>
33
+ !(
34
+ n instanceof HTMLElement &&
35
+ n.hasAttribute('data-search-highlight')
36
+ ),
37
+ ) ||
38
+ removedNodes.some(
39
+ (n) =>
40
+ !(
41
+ n instanceof HTMLElement &&
42
+ n.hasAttribute('data-search-highlight')
43
+ ),
44
+ )
45
+ )
46
+ })
47
+
48
+ if (hasExternalChanges) {
49
+ run()
50
+ }
51
+ })
52
+
53
+ // Function to run highlighting
54
+ function run() {
55
+ cancelAnimationFrame(rafId)
56
+ rafId = requestAnimationFrame(() => {
57
+ // Disconnect to avoid observing our own cleanup/highlight cycle
58
+ observer.disconnect()
59
+ clearHighlights(containerSelector)
60
+
61
+ // Split query into individual words (minimum 2 chars)
62
+ const terms = query!
63
+ .split(/\s+/)
64
+ .map((t) => t.trim())
65
+ .filter((t) => t.length >= 2)
66
+
67
+ if (terms.length > 0) {
68
+ highlightTerms(container!, terms)
69
+ }
70
+
71
+ // Re-observe
72
+ observer.observe(container!, { childList: true, subtree: true })
73
+ })
74
+ }
75
+
76
+ // Initial run
77
+ run()
78
+
79
+ return () => {
80
+ cancelAnimationFrame(rafId)
81
+ observer.disconnect()
82
+ clearHighlights(containerSelector)
83
+ }
84
+ }, [query, search, containerSelector])
85
+ }
86
+
87
+ function clearHighlights(selector: string) {
88
+ const marks = document.querySelectorAll(
89
+ `${selector} mark[data-search-highlight]`,
90
+ )
91
+ marks.forEach((mark) => {
92
+ try {
93
+ const parent = mark.parentNode
94
+ if (parent && parent.contains(mark)) {
95
+ const text = mark.textContent || ''
96
+ parent.replaceChild(document.createTextNode(text), mark)
97
+ }
98
+ } catch (e) {
99
+ // Ignore DOM errors during cleanup
100
+ }
101
+ })
102
+ }
103
+
104
+ function highlightTerms(container: Element, terms: string[]) {
105
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
106
+ acceptNode: (node) => {
107
+ const parent = node.parentElement
108
+ if (
109
+ parent &&
110
+ (parent.tagName === 'SCRIPT' ||
111
+ parent.tagName === 'STYLE' ||
112
+ parent.tagName === 'MARK' ||
113
+ parent.closest('pre') ||
114
+ parent.closest('code'))
115
+ ) {
116
+ return NodeFilter.FILTER_REJECT
117
+ }
118
+ return NodeFilter.FILTER_ACCEPT
119
+ },
120
+ })
121
+
122
+ const nodes: Text[] = []
123
+ let node: Node | null
124
+ while ((node = walker.nextNode())) {
125
+ nodes.push(node as Text)
126
+ }
127
+
128
+ // Create a combined regex for all terms
129
+ // Accent-insensitive helper: replaces 'a' with '[aáàä...]'
130
+ const accentMap: Record<string, string> = {
131
+ a: '[aáàäâã]',
132
+ e: '[eéèëê]',
133
+ i: '[iíìïî]',
134
+ o: '[oóòöôõ]',
135
+ u: '[uúùüû]',
136
+ n: '[nñ]',
137
+ c: '[cç]',
138
+ }
139
+
140
+ const prepareRegex = (term: string) => {
141
+ let pattern = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
142
+ // Make it accent insensitive
143
+ pattern = pattern
144
+ .split('')
145
+ .map((char) => {
146
+ const lower = char.toLowerCase()
147
+ return accentMap[lower] || char
148
+ })
149
+ .join('')
150
+ return pattern
151
+ }
152
+
153
+ const combinedPattern = terms.map(prepareRegex).join('|')
154
+ const regex = new RegExp(`(${combinedPattern})`, 'gi')
155
+
156
+ const matchRegexes = terms.map((term) => {
157
+ const p = prepareRegex(term)
158
+ return new RegExp(`^${p}$`, 'i')
159
+ })
160
+
161
+ nodes.forEach((textNode) => {
162
+ const text = textNode.textContent
163
+ if (text && regex.test(text)) {
164
+ const fragment = document.createDocumentFragment()
165
+ const parts = text.split(regex)
166
+
167
+ parts.forEach((part) => {
168
+ const isMatch = matchRegexes.some((rx) => rx.test(part))
169
+
170
+ if (isMatch) {
171
+ const mark = document.createElement('mark')
172
+ mark.textContent = part
173
+ mark.setAttribute('data-search-highlight', 'true')
174
+ fragment.appendChild(mark)
175
+ } else if (part) {
176
+ fragment.appendChild(document.createTextNode(part))
177
+ }
178
+ })
179
+
180
+ if (textNode.parentNode) {
181
+ textNode.parentNode.replaceChild(fragment, textNode)
182
+ }
183
+ }
184
+ })
185
+ }
@@ -2,7 +2,7 @@ import { useState, useMemo, useEffect } from 'react'
2
2
  import { Index } from 'flexsearch'
3
3
  import { useRoutes } from './use-routes'
4
4
  import type { ComponentRoute } from '../types'
5
- // @ts-ignore
5
+ // @ts-expect-error
6
6
  import searchData from 'virtual:boltdocs-search'
7
7
 
8
8
  interface SearchDataItem {
@@ -40,6 +40,15 @@ export function useSearch(routes: ComponentRoute[]) {
40
40
  setIndex(newIndex)
41
41
  }, [isOpen, index])
42
42
 
43
+ // Pre-index searchData for O(1) lookups
44
+ const searchDataMap = useMemo(() => {
45
+ const map = new Map<string, SearchDataItem>()
46
+ for (const doc of searchData as SearchDataItem[]) {
47
+ map.set(doc.id, doc)
48
+ }
49
+ return map
50
+ }, [])
51
+
43
52
  const list = useMemo(() => {
44
53
  if (!query) {
45
54
  // Default results: just active routes
@@ -70,7 +79,7 @@ export function useSearch(routes: ComponentRoute[]) {
70
79
  const seen = new Set<string>()
71
80
 
72
81
  for (const id of searchResults) {
73
- const doc = (searchData as SearchDataItem[]).find((d) => d.id === id)
82
+ const doc = searchDataMap.get(id as string)
74
83
  if (!doc) continue
75
84
 
76
85
  // Filter by locale and version
@@ -92,7 +101,7 @@ export function useSearch(routes: ComponentRoute[]) {
92
101
  }
93
102
 
94
103
  return results.slice(0, 10)
95
- }, [query, index, currentLocale, currentVersion, routes])
104
+ }, [query, index, currentLocale, currentVersion, routes, searchDataMap])
96
105
 
97
106
  return {
98
107
  isOpen,