@myelophone/nuxt 0.18.2

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 (259) hide show
  1. package/.devcontainer/Dockerfile +7 -0
  2. package/.devcontainer/devcontainer.json +21 -0
  3. package/.editorconfig +43 -0
  4. package/.github/workflows/package.yml +73 -0
  5. package/.nuxtignore +6 -0
  6. package/.releaserc.js +132 -0
  7. package/.yarn/releases/yarn-4.18.0.cjs +1000 -0
  8. package/.yarnrc.yml +8 -0
  9. package/CHANGELOG.md +141 -0
  10. package/LICENSE +77 -0
  11. package/README.md +3004 -0
  12. package/app/app.vue +455 -0
  13. package/app/assets/css/animation.css +81 -0
  14. package/app/assets/css/backgrounds.css +57 -0
  15. package/app/assets/css/base.css +182 -0
  16. package/app/assets/css/index.css +9 -0
  17. package/app/assets/css/links.css +12 -0
  18. package/app/assets/css/multimedia.css +102 -0
  19. package/app/assets/css/preloader.css +68 -0
  20. package/app/assets/css/print.css +40 -0
  21. package/app/assets/css/tailwind.css +20 -0
  22. package/app/assets/css/theme.css +29 -0
  23. package/app/components/consent/GoogleMap.vue +59 -0
  24. package/app/components/consent/Youtube.vue +27 -0
  25. package/app/components/cookie/Banner.vue +70 -0
  26. package/app/components/cookie/ConsentWrapper.vue +71 -0
  27. package/app/components/cookie/PrivacyPolicy.vue +179 -0
  28. package/app/components/cookie/SettingsModal.vue +387 -0
  29. package/app/components/grid/Col.vue +262 -0
  30. package/app/components/grid/Container.vue +236 -0
  31. package/app/components/grid/Row.vue +248 -0
  32. package/app/components/grid/Stack.vue +233 -0
  33. package/app/components/myelophone/Copyright.vue +5 -0
  34. package/app/components/myelophone/Welcome.vue +107 -0
  35. package/app/components/page/FullscreenPreloader.vue +177 -0
  36. package/app/components/page/Preloader.vue +18 -0
  37. package/app/components/page/ReadyLocalized.vue +19 -0
  38. package/app/components/ready/.gitkeep +0 -0
  39. package/app/components/safe/Email.vue +52 -0
  40. package/app/components/safe/ImgWithLoader.vue +180 -0
  41. package/app/components/safe/NuxtImg.vue +25 -0
  42. package/app/components/safe/NuxtPicture.vue +29 -0
  43. package/app/components/section/.gitkeep +0 -0
  44. package/app/components/seo/BrandInfo.vue +76 -0
  45. package/app/components/seo/ContentNoIndex.vue +35 -0
  46. package/app/components/seo/NoIndex.vue +22 -0
  47. package/app/components/seo/OrgInfo.vue +130 -0
  48. package/app/components/seo/ProfileInfo.vue +137 -0
  49. package/app/components/ui/Accordion.vue +163 -0
  50. package/app/components/ui/Alert.vue +179 -0
  51. package/app/components/ui/Avatar.vue +88 -0
  52. package/app/components/ui/Badge.vue +150 -0
  53. package/app/components/ui/Banner.vue +133 -0
  54. package/app/components/ui/Button.vue +175 -0
  55. package/app/components/ui/Card.vue +66 -0
  56. package/app/components/ui/Checkbox.vue +134 -0
  57. package/app/components/ui/Chip.vue +136 -0
  58. package/app/components/ui/CommandPalette.vue +434 -0
  59. package/app/components/ui/Console.vue +583 -0
  60. package/app/components/ui/CursorCreative.vue +148 -0
  61. package/app/components/ui/FeatureAccordion.vue +165 -0
  62. package/app/components/ui/FileUpload.vue +705 -0
  63. package/app/components/ui/GeoDependent.vue +72 -0
  64. package/app/components/ui/Heading.vue +51 -0
  65. package/app/components/ui/Icon.vue +42 -0
  66. package/app/components/ui/Icon8.vue +39 -0
  67. package/app/components/ui/Input.vue +88 -0
  68. package/app/components/ui/LangLink.vue +34 -0
  69. package/app/components/ui/LangVisible.vue +33 -0
  70. package/app/components/ui/LanguageSelect.vue +202 -0
  71. package/app/components/ui/LightBox.vue +155 -0
  72. package/app/components/ui/LocalTime.vue +170 -0
  73. package/app/components/ui/Modal.vue +161 -0
  74. package/app/components/ui/Popup.vue +200 -0
  75. package/app/components/ui/PostContent.server.vue +23 -0
  76. package/app/components/ui/ProtectedEmail.vue +52 -0
  77. package/app/components/ui/RelativeTime.vue +141 -0
  78. package/app/components/ui/ScheduledContent.vue +150 -0
  79. package/app/components/ui/ScrollToTop.client.vue +48 -0
  80. package/app/components/ui/SegmentedControl.vue +148 -0
  81. package/app/components/ui/SiteSearch.vue +1191 -0
  82. package/app/components/ui/Skeleton.vue +91 -0
  83. package/app/components/ui/SmartContrast.vue +74 -0
  84. package/app/components/ui/SnapContainer.vue +222 -0
  85. package/app/components/ui/SnapSection.vue +294 -0
  86. package/app/components/ui/SonnerStyle.vue +7 -0
  87. package/app/components/ui/SplitSection.vue +99 -0
  88. package/app/components/ui/Steps.vue +858 -0
  89. package/app/components/ui/StickyWrapper.vue +46 -0
  90. package/app/components/ui/Table.vue +251 -0
  91. package/app/components/ui/Tabs.vue +451 -0
  92. package/app/components/ui/TextColumn.vue +36 -0
  93. package/app/components/ui/Textarea.vue +251 -0
  94. package/app/components/ui/Toggler.vue +40 -0
  95. package/app/components/ui/Tooltip.vue +524 -0
  96. package/app/components/ui/TruncateText.vue +105 -0
  97. package/app/components/ui/ViewportSpacer.vue +72 -0
  98. package/app/components/view/AnimatedCounter.vue +121 -0
  99. package/app/components/view/AnnouncementBar.vue +104 -0
  100. package/app/components/view/AvatarGroup.vue +104 -0
  101. package/app/components/view/Breadcrumbs.vue +97 -0
  102. package/app/components/view/ContentWithToc.vue +152 -0
  103. package/app/components/view/Copyright.vue +68 -0
  104. package/app/components/view/CountdownTimer.vue +184 -0
  105. package/app/components/view/CreativeCTA.vue +143 -0
  106. package/app/components/view/CurrencySelect.vue +254 -0
  107. package/app/components/view/FeatureBoxGrid.vue +142 -0
  108. package/app/components/view/HorizontalMenu.vue +97 -0
  109. package/app/components/view/ImageCompare.vue +141 -0
  110. package/app/components/view/ImgSlider.vue +333 -0
  111. package/app/components/view/InfiniteMarquee.vue +147 -0
  112. package/app/components/view/InfoBar.vue +86 -0
  113. package/app/components/view/LogoGrid.vue +106 -0
  114. package/app/components/view/LogoSlider.vue +83 -0
  115. package/app/components/view/NotFound.vue +81 -0
  116. package/app/components/view/PartialBackground.vue +313 -0
  117. package/app/components/view/QuoteBig.vue +111 -0
  118. package/app/components/view/ResponsiveMenu.vue +219 -0
  119. package/app/components/view/ReviewsSlider.vue +230 -0
  120. package/app/components/view/SocialBar.vue +78 -0
  121. package/app/components/view/StickyHeadingContent.vue +158 -0
  122. package/app/components/view/TallyForm.vue +295 -0
  123. package/app/components/view/WarningOutdated.vue +113 -0
  124. package/app/composables/useApi.ts +88 -0
  125. package/app/composables/useAppSeo.ts +78 -0
  126. package/app/composables/useBreadcrumbs.ts +106 -0
  127. package/app/composables/useChangeUrl.ts +73 -0
  128. package/app/composables/useCommand.ts +28 -0
  129. package/app/composables/useCookieControl.ts +111 -0
  130. package/app/composables/useCookieModal.ts +9 -0
  131. package/app/composables/useDevice.ts +109 -0
  132. package/app/composables/useGeoFetch.ts +139 -0
  133. package/app/composables/useLoadScript.ts +36 -0
  134. package/app/composables/useModalState.ts +37 -0
  135. package/app/composables/useMultiLang.ts +278 -0
  136. package/app/composables/useNetwork.ts +49 -0
  137. package/app/composables/useNuxtVersion.ts +5 -0
  138. package/app/composables/usePiniaStore.ts +5 -0
  139. package/app/composables/usePopupControl.ts +57 -0
  140. package/app/composables/useQuery.ts +307 -0
  141. package/app/composables/useReadyPage.ts +182 -0
  142. package/app/composables/useSafeList.ts +90 -0
  143. package/app/composables/useScriptLoader.ts +221 -0
  144. package/app/composables/useSiteSearch.ts +32 -0
  145. package/app/composables/useSnapScroll.ts +24 -0
  146. package/app/composables/useStatic.ts +5 -0
  147. package/app/composables/useStorage.ts +252 -0
  148. package/app/composables/useStoreBroadcast.ts +146 -0
  149. package/app/composables/useThemeSync.ts +81 -0
  150. package/app/constants/layout.ts +109 -0
  151. package/app/constants/predefinedCookieScripts.ts +1222 -0
  152. package/app/constants/ui-events.ts +1 -0
  153. package/app/error.vue +169 -0
  154. package/app/layouts/default-blog.vue +5 -0
  155. package/app/layouts/default.vue +3 -0
  156. package/app/locales/en/commands.json +5 -0
  157. package/app/locales/en/cookies.json +182 -0
  158. package/app/locales/en/error.json +4 -0
  159. package/app/locales/en/interface.json +33 -0
  160. package/app/locales/en/myelophone.json +8 -0
  161. package/app/locales/en/outdated.json +5 -0
  162. package/app/locales/en/times.json +18 -0
  163. package/app/locales/pl/commands.json +5 -0
  164. package/app/locales/pl/cookies.json +182 -0
  165. package/app/locales/pl/error.json +4 -0
  166. package/app/locales/pl/interface.json +33 -0
  167. package/app/locales/pl/myelophone.json +8 -0
  168. package/app/locales/pl/outdated.json +5 -0
  169. package/app/locales/pl/times.json +18 -0
  170. package/app/locales/ru/commands.json +5 -0
  171. package/app/locales/ru/cookies.json +182 -0
  172. package/app/locales/ru/error.json +4 -0
  173. package/app/locales/ru/interface.json +33 -0
  174. package/app/locales/ru/myelophone.json +8 -0
  175. package/app/locales/ru/outdated.json +5 -0
  176. package/app/locales/ru/times.json +18 -0
  177. package/app/middleware/blog-layout.ts +28 -0
  178. package/app/middleware/i18n.global.ts +17 -0
  179. package/app/middleware/not-found-status.ts +11 -0
  180. package/app/modules/default-blog/index.ts +39 -0
  181. package/app/modules/default-blog/posts.ts +80 -0
  182. package/app/modules/default-sitemap/index.ts +116 -0
  183. package/app/modules/default-sitemap/runtime/sitemap.get.ts +42 -0
  184. package/app/modules/fullscreen-preloader/module.ts +43 -0
  185. package/app/modules/htaccess/.htaccess +38 -0
  186. package/app/modules/htaccess/index.ts +23 -0
  187. package/app/modules/i18n-shaker.mjs +221 -0
  188. package/app/modules/multi18n/module.ts +71 -0
  189. package/app/modules/myelophone-config/module.ts +241 -0
  190. package/app/pages/[...all].vue +9 -0
  191. package/app/pages/index.vue +3 -0
  192. package/app/pages/post/[...slug].vue +30 -0
  193. package/app/plugins/bfcache-refresh.client.ts +9 -0
  194. package/app/plugins/canonical.ts +30 -0
  195. package/app/plugins/csp-nonce.server.ts +41 -0
  196. package/app/plugins/multi18n.client.ts +21 -0
  197. package/app/plugins/multi18n.server.ts +15 -0
  198. package/app/plugins/nuxt-version.client.ts +4 -0
  199. package/app/plugins/restore.client.ts +21 -0
  200. package/app/plugins/reveal.ts +89 -0
  201. package/app/plugins/url-helper.client.ts +66 -0
  202. package/app/server/api/exchange-rates.get.ts +216 -0
  203. package/app/server/api/healthz.ts +1 -0
  204. package/app/server/api/logout.post.ts +6 -0
  205. package/app/server/middleware/normalize-url.ts +14 -0
  206. package/app/server/middleware/security-headers.ts +147 -0
  207. package/app/server/middleware/url-helper.ts +43 -0
  208. package/app/server/plugins/clean-html-spaces.ts +15 -0
  209. package/app/server/plugins/clean-styles.ts +94 -0
  210. package/app/server/plugins/compression.ts +122 -0
  211. package/app/server/plugins/css-preload.ts +26 -0
  212. package/app/server/plugins/early-hints.ts +31 -0
  213. package/app/server/plugins/force-noindex.ts +43 -0
  214. package/app/server/plugins/html-one-line.ts +17 -0
  215. package/app/server/plugins/inline-style.ts +7 -0
  216. package/app/server/routes/healthz.get.ts +1 -0
  217. package/app/server/tsconfig.json +3 -0
  218. package/app/stores/cart.ts +945 -0
  219. package/app/stores/commands.ts +43 -0
  220. package/app/stores/device.ts +156 -0
  221. package/app/stores/querySync.ts +5 -0
  222. package/app/stores/settings.ts +145 -0
  223. package/app/stores/user.ts +607 -0
  224. package/app/types/cookie.ts +89 -0
  225. package/app/types/myelophone.d.ts +87 -0
  226. package/app/types/site-search.ts +110 -0
  227. package/app/utils/asyncRefresh.ts +4 -0
  228. package/app/utils/blog.server.ts +142 -0
  229. package/app/utils/jsonLd.ts +56 -0
  230. package/app/utils/myelophoneConfig.ts +4 -0
  231. package/app/utils/sortBy.ts +18 -0
  232. package/app/utils/transliterate.ts +75 -0
  233. package/app/utils/ytVideo.ts +37 -0
  234. package/app/workers/site-search.worker.ts +661 -0
  235. package/biome.json +94 -0
  236. package/commitlint.config.js +3 -0
  237. package/nuxt.config.ts +539 -0
  238. package/package.json +101 -0
  239. package/playground/content/.gitkeep +0 -0
  240. package/playground/locales/.gitkeep +0 -0
  241. package/playground/myelophone.dev.ts +45 -0
  242. package/playground/nuxt.config.ts +105 -0
  243. package/playground/package.json +5 -0
  244. package/playground/pages/.gitkeep +0 -0
  245. package/playground/tsconfig.json +3 -0
  246. package/playwright.config.ts +23 -0
  247. package/public/browsers/chrome.svg +77 -0
  248. package/public/browsers/edge.svg +98 -0
  249. package/public/browsers/firefox.svg +300 -0
  250. package/public/favicon.ico +0 -0
  251. package/public/myelophone_eng.png +0 -0
  252. package/public/myelophone_eng_white.png +0 -0
  253. package/spa-loading-template.html +72 -0
  254. package/tests/home.spec.ts +9 -0
  255. package/tests/utils.ts +23 -0
  256. package/tsconfig.json +16 -0
  257. package/vite/plugins/clean-css.ts +190 -0
  258. package/vite/plugins/postcss-add-viewportunits.ts +33 -0
  259. package/vite/plugins/postcss-viewport-fallback.ts +49 -0
@@ -0,0 +1,1191 @@
1
+ <template>
2
+ <div v-if="$slots.trigger" class="contents">
3
+ <slot name="trigger" :open="open" :close="close" :is-open="isOpen" />
4
+ </div>
5
+
6
+ <Teleport v-if="mounted" to="body">
7
+ <Transition
8
+ enter-active-class="transition duration-150 ease-out"
9
+ enter-from-class="opacity-0"
10
+ enter-to-class="opacity-100"
11
+ leave-active-class="transition duration-100 ease-in"
12
+ leave-from-class="opacity-100"
13
+ leave-to-class="opacity-0"
14
+ >
15
+ <div
16
+ v-if="isOpen"
17
+ class="fixed inset-0 z-99999 flex items-start justify-center px-3 pt-[max(1rem,8dvh)] sm:px-4"
18
+ role="dialog"
19
+ aria-modal="true"
20
+ :aria-label="ariaLabel || t('interface.siteSearch.ariaLabel')"
21
+ @keydown.esc="close"
22
+ >
23
+ <div
24
+ class="absolute inset-0 bg-black/45 backdrop-blur-[2px]"
25
+ @click="close"
26
+ />
27
+
28
+ <div
29
+ class="relative flex max-h-[min(42rem,calc(100dvh-2rem))] w-full max-w-2xl flex-col overflow-hidden rounded-xl bg-(--ui-bg) text-(--ui-text) shadow-2xl ring-1 ring-(--ui-text)/20"
30
+ :class="contentClass"
31
+ @click.stop
32
+ >
33
+ <div
34
+ class="flex min-h-14 items-center gap-3 border-b border-(--ui-text)/10 px-4"
35
+ >
36
+ <svg
37
+ class="size-5 shrink-0 opacity-45"
38
+ aria-hidden="true"
39
+ viewBox="0 0 24 24"
40
+ fill="none"
41
+ stroke="currentColor"
42
+ >
43
+ <path
44
+ stroke-linecap="round"
45
+ stroke-linejoin="round"
46
+ stroke-width="2"
47
+ d="m21 21-4.35-4.35m2.35-5.65a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z"
48
+ />
49
+ </svg>
50
+ <input
51
+ ref="searchInput"
52
+ v-model="query"
53
+ type="search"
54
+ class="site-search-input h-14 min-w-0 flex-1 border-0 bg-transparent text-base text-(--ui-text) outline-none placeholder:text-(--ui-text)/35"
55
+ :placeholder="
56
+ placeholder ||
57
+ t('interface.siteSearch.placeholder')
58
+ "
59
+ :aria-label="
60
+ ariaLabel || t('interface.siteSearch.ariaLabel')
61
+ "
62
+ autocomplete="off"
63
+ spellcheck="false"
64
+ @input="activateSearch"
65
+ @keydown.down.prevent="moveSelection(1)"
66
+ @keydown.up.prevent="moveSelection(-1)"
67
+ @keydown.enter.prevent="onEnter"
68
+ />
69
+ <button
70
+ v-if="query"
71
+ type="button"
72
+ class="grid size-8 shrink-0 place-items-center rounded-md text-(--ui-text)/45 transition-colors hover:bg-(--ui-text)/8 hover:text-(--ui-text) focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-(--ui-text)/50"
73
+ :aria-label="t('interface.siteSearch.clear')"
74
+ :title="t('interface.siteSearch.clear')"
75
+ @click="clearQuery"
76
+ >
77
+ <svg
78
+ class="size-4"
79
+ aria-hidden="true"
80
+ viewBox="0 0 24 24"
81
+ fill="none"
82
+ stroke="currentColor"
83
+ >
84
+ <path
85
+ stroke-linecap="round"
86
+ stroke-width="2"
87
+ d="m6 6 12 12M18 6 6 18"
88
+ />
89
+ </svg>
90
+ </button>
91
+ <select
92
+ v-model="activeOperator"
93
+ class="h-8 shrink-0 rounded-md border border-(--ui-text)/15 bg-(--ui-bg) px-1.5 font-mono text-[11px] font-semibold text-(--ui-text) outline-none"
94
+ :aria-label="
95
+ t('interface.siteSearch.operatorLabel')
96
+ "
97
+ @change="changeOperator"
98
+ >
99
+ <option value="and">AND</option>
100
+ <option value="or">OR</option>
101
+ </select>
102
+ <span
103
+ class="hidden rounded border border-(--ui-text)/15 px-1.5 py-0.5 font-mono text-[10px] opacity-45 sm:block"
104
+ >ESC</span
105
+ >
106
+ </div>
107
+
108
+ <div
109
+ class="min-h-24 flex-1 overflow-y-auto overscroll-contain p-2"
110
+ >
111
+ <div
112
+ v-if="status === 'indexing'"
113
+ class="flex min-h-28 flex-col items-center justify-center gap-3 px-5 text-center"
114
+ >
115
+ <div
116
+ class="size-5 animate-spin rounded-full border-2 border-(--ui-text)/15 border-t-(--ui-text)/70"
117
+ />
118
+ <p class="text-sm opacity-60">
119
+ {{ indexingLabel }}
120
+ </p>
121
+ <div
122
+ v-if="totalPages"
123
+ class="h-1 w-full max-w-52 overflow-hidden rounded-full bg-(--ui-text)/10"
124
+ >
125
+ <div
126
+ class="h-full bg-(--ui-text)/55 transition-[width]"
127
+ :style="{ width: `${indexProgress}%` }"
128
+ />
129
+ </div>
130
+ </div>
131
+
132
+ <div
133
+ v-else-if="status === 'error'"
134
+ class="flex min-h-28 items-center justify-center px-5 text-center text-sm text-red-500"
135
+ >
136
+ <slot name="error" :message="errorMessage">{{
137
+ t("interface.siteSearch.error")
138
+ }}</slot>
139
+ </div>
140
+
141
+ <div
142
+ v-else-if="query.trim().length < minQueryLength"
143
+ class="flex min-h-28 items-center justify-center px-5 text-center text-sm opacity-45"
144
+ >
145
+ {{
146
+ t("interface.siteSearch.typeMore", {
147
+ count: minQueryLength,
148
+ })
149
+ }}
150
+ </div>
151
+
152
+ <div
153
+ v-else-if="!searchArmed"
154
+ class="flex min-h-28 items-center justify-center px-5 text-center text-sm opacity-45"
155
+ >
156
+ {{ t("interface.siteSearch.prefilled") }}
157
+ </div>
158
+
159
+ <div
160
+ v-else-if="searching"
161
+ class="flex min-h-28 items-center justify-center px-5 text-center text-sm opacity-50"
162
+ >
163
+ {{ t("interface.siteSearch.searching") }}
164
+ </div>
165
+
166
+ <ul
167
+ v-else-if="results.length"
168
+ class="site-search-results"
169
+ role="listbox"
170
+ :aria-label="t('interface.siteSearch.results')"
171
+ >
172
+ <li
173
+ v-for="(result, index) in results"
174
+ :key="result.id"
175
+ role="option"
176
+ :aria-selected="selectedIndex === index"
177
+ >
178
+ <NuxtLink
179
+ :to="resultTarget(result)"
180
+ class="group block min-h-28 w-full rounded-lg px-3 py-3 text-left transition-colors"
181
+ :class="
182
+ selectedIndex === index
183
+ ? 'bg-(--ui-text)/10'
184
+ : 'hover:bg-(--ui-text)/5'
185
+ "
186
+ :data-site-search-index="index"
187
+ @click="selectResult(result)"
188
+ @mouseenter="selectedIndex = index"
189
+ >
190
+ <slot
191
+ name="result"
192
+ :result="result"
193
+ :selected="selectedIndex === index"
194
+ >
195
+ <div
196
+ class="flex items-start justify-between gap-3"
197
+ >
198
+ <span class="min-w-0 font-medium">
199
+ <template
200
+ v-for="(
201
+ segment, segmentIndex
202
+ ) in highlightedSegments(
203
+ result.title,
204
+ result.titleMatches,
205
+ )"
206
+ :key="segmentIndex"
207
+ >
208
+ <mark
209
+ v-if="
210
+ segment.highlighted
211
+ "
212
+ class="site-search-mark"
213
+ >{{
214
+ segment.text
215
+ }}</mark
216
+ >
217
+ <template v-else>{{
218
+ segment.text
219
+ }}</template>
220
+ </template>
221
+ </span>
222
+ <span
223
+ v-if="showLocaleBadge"
224
+ class="shrink-0 rounded bg-(--ui-text)/7 px-1.5 py-0.5 font-mono text-[10px] uppercase opacity-55"
225
+ >{{ result.locale }}</span
226
+ >
227
+ </div>
228
+ <p
229
+ v-if="
230
+ result.snippet ||
231
+ result.description
232
+ "
233
+ class="mt-1 line-clamp-2 text-sm leading-5 opacity-55"
234
+ >
235
+ <template
236
+ v-for="(
237
+ segment, segmentIndex
238
+ ) in highlightedSegments(
239
+ result.snippet ||
240
+ result.description,
241
+ result.snippetMatches,
242
+ )"
243
+ :key="segmentIndex"
244
+ >
245
+ <mark
246
+ v-if="segment.highlighted"
247
+ class="site-search-mark"
248
+ >{{ segment.text }}</mark
249
+ >
250
+ <template v-else>{{
251
+ segment.text
252
+ }}</template>
253
+ </template>
254
+ </p>
255
+ <p
256
+ class="mt-1.5 truncate font-mono text-[11px] opacity-35"
257
+ >
258
+ <template
259
+ v-for="(
260
+ segment, segmentIndex
261
+ ) in highlightedSegments(
262
+ result.path,
263
+ result.pathMatches,
264
+ )"
265
+ :key="segmentIndex"
266
+ >
267
+ <mark
268
+ v-if="segment.highlighted"
269
+ class="site-search-mark"
270
+ >{{ segment.text }}</mark
271
+ >
272
+ <template v-else>{{
273
+ segment.text
274
+ }}</template>
275
+ </template>
276
+ </p>
277
+ </slot>
278
+ </NuxtLink>
279
+ </li>
280
+ </ul>
281
+
282
+ <div
283
+ v-else
284
+ class="flex min-h-28 items-center justify-center px-5 text-center text-sm opacity-45"
285
+ >
286
+ <slot name="empty" :query="query">{{
287
+ t("interface.siteSearch.noResults")
288
+ }}</slot>
289
+ </div>
290
+ </div>
291
+
292
+ <div
293
+ v-if="status === 'ready'"
294
+ class="flex items-center justify-between gap-3 border-t border-(--ui-text)/10 px-4 py-2 text-[11px] opacity-40"
295
+ >
296
+ <span v-if="resolvedStrategy === 'client'">{{
297
+ t("interface.siteSearch.indexed", {
298
+ count: indexedCount,
299
+ })
300
+ }}</span>
301
+ <span v-else>{{
302
+ t("interface.siteSearch.remote")
303
+ }}</span>
304
+ <span class="hidden sm:inline">↑↓ · Enter</span>
305
+ </div>
306
+ </div>
307
+ </div>
308
+ </Transition>
309
+ </Teleport>
310
+ </template>
311
+
312
+ <script setup lang="ts">
313
+ import { UI_SITE_SEARCH_OPEN_EVENT } from "~/constants/ui-events";
314
+ import type {
315
+ SiteSearchOperator,
316
+ SiteSearchRange,
317
+ SiteSearchRemoteResponse,
318
+ SiteSearchResult,
319
+ SiteSearchStrategy,
320
+ SiteSearchWorkerRequest,
321
+ SiteSearchWorkerResponse,
322
+ } from "~/types/site-search";
323
+
324
+ type UrlMode = "search" | "prefill";
325
+ type SearchStatus = "idle" | "indexing" | "ready" | "error";
326
+
327
+ interface Props {
328
+ modelValue?: boolean;
329
+ minQueryLength?: number;
330
+ debounce?: number;
331
+ limit?: number;
332
+ maxPages?: number;
333
+ maxContentLength?: number;
334
+ concurrency?: number;
335
+ cacheTtl?: number;
336
+ respectNoIndex?: boolean;
337
+ discoverSitemaps?: boolean;
338
+ strategy?: SiteSearchStrategy;
339
+ endpoint?: string;
340
+ searchAllLocales?: boolean;
341
+ locale?: string;
342
+ shortcut?: boolean;
343
+ shortcutKey?: string;
344
+ shortcutShift?: boolean;
345
+ queryParam?: string;
346
+ modeParam?: string;
347
+ operatorParam?: string;
348
+ operator?: SiteSearchOperator;
349
+ urlMode?: UrlMode;
350
+ consumeUrl?: boolean;
351
+ placeholder?: string;
352
+ ariaLabel?: string;
353
+ contentClass?: string;
354
+ disableUrlHandling?: boolean;
355
+ }
356
+
357
+ const props = withDefaults(defineProps<Props>(), {
358
+ modelValue: undefined,
359
+ minQueryLength: 2,
360
+ debounce: 100,
361
+ limit: 10,
362
+ maxPages: 500,
363
+ maxContentLength: 30_000,
364
+ concurrency: 4,
365
+ cacheTtl: 30 * 60 * 1000,
366
+ respectNoIndex: true,
367
+ discoverSitemaps: true,
368
+ strategy: "client",
369
+ endpoint: "",
370
+ searchAllLocales: false,
371
+ locale: "",
372
+ shortcut: true,
373
+ shortcutKey: "k",
374
+ shortcutShift: true,
375
+ queryParam: "search",
376
+ modeParam: "searchMode",
377
+ operatorParam: "searchOperator",
378
+ operator: "and",
379
+ urlMode: "search",
380
+ consumeUrl: false,
381
+ placeholder: "",
382
+ ariaLabel: "",
383
+ contentClass: "",
384
+ disableUrlHandling: false,
385
+ });
386
+
387
+ const emit = defineEmits<{
388
+ "update:modelValue": [value: boolean];
389
+ "update:operator": [value: SiteSearchOperator];
390
+ open: [];
391
+ close: [];
392
+ select: [result: SiteSearchResult];
393
+ ready: [count: number];
394
+ error: [message: string];
395
+ }>();
396
+
397
+ const route = useRoute();
398
+ const router = useRouter();
399
+ const runtimeConfig = useRuntimeConfig();
400
+ const settings = useSettingsStore(useNuxtApp().$pinia);
401
+ const { t } = useMultiLang("interface");
402
+
403
+ const mounted = ref(false);
404
+ const internalOpen = ref(false);
405
+ const query = ref("");
406
+ const activeOperator = ref<SiteSearchOperator>(props.operator);
407
+ const results = ref<SiteSearchResult[]>([]);
408
+ const selectedIndex = ref(0);
409
+ const searchInput = ref<HTMLInputElement | null>(null);
410
+ const status = ref<SearchStatus>("idle");
411
+ const searching = ref(false);
412
+ const searchArmed = ref(true);
413
+ const indexedPages = ref(0);
414
+ const totalPages = ref(0);
415
+ const indexedCount = ref(0);
416
+ const errorMessage = ref("");
417
+
418
+ let worker: Worker | undefined;
419
+ let remoteController: AbortController | undefined;
420
+ let requestId = 0;
421
+ let initRequestId = 0;
422
+ let latestSearchRequestId = 0;
423
+ let searchTimer: ReturnType<typeof setTimeout> | undefined;
424
+ let lastUrlSignature = "";
425
+ let internalCloseHandled = false;
426
+
427
+ const isControlled = computed(() => props.modelValue !== undefined);
428
+ const isOpen = computed(() =>
429
+ isControlled.value ? Boolean(props.modelValue) : internalOpen.value,
430
+ );
431
+ const resolvedStrategy = computed<SiteSearchStrategy>(() =>
432
+ props.strategy === "remote" && props.endpoint.trim() ? "remote" : "client",
433
+ );
434
+ const activeLocale = computed(
435
+ () =>
436
+ props.locale ||
437
+ settings.currentLocale ||
438
+ settings.defaultLocale ||
439
+ documentLocale() ||
440
+ "en",
441
+ );
442
+ const showLocaleBadge = computed(
443
+ () => props.searchAllLocales || settings.locales.length > 1,
444
+ );
445
+ const indexProgress = computed(() =>
446
+ totalPages.value
447
+ ? Math.min(
448
+ 100,
449
+ Math.round((indexedPages.value / totalPages.value) * 100),
450
+ )
451
+ : 0,
452
+ );
453
+ const indexingLabel = computed(() =>
454
+ totalPages.value
455
+ ? t("interface.siteSearch.indexingProgress", {
456
+ indexed: indexedPages.value,
457
+ total: totalPages.value,
458
+ })
459
+ : t("interface.siteSearch.indexing"),
460
+ );
461
+ const requestedResultLimit = computed(() =>
462
+ Math.min(Math.max(1, props.limit), 10),
463
+ );
464
+
465
+ function documentLocale() {
466
+ return import.meta.client
467
+ ? document.documentElement.lang.split("-")[0]
468
+ : "";
469
+ }
470
+
471
+ function setOpen(value: boolean) {
472
+ if (!isControlled.value) internalOpen.value = value;
473
+ emit("update:modelValue", value);
474
+ if (value) emit("open");
475
+ else emit("close");
476
+ }
477
+
478
+ function getSeedUrls() {
479
+ const paths = router
480
+ .getRoutes()
481
+ .map((record) => record.path)
482
+ .filter((path) => path && !/[:*]/.test(path));
483
+ paths.push(route.path || "/");
484
+ return [...new Set(paths)];
485
+ }
486
+
487
+ function post(message: SiteSearchWorkerRequest) {
488
+ try {
489
+ worker?.postMessage(message);
490
+ } catch (error) {
491
+ fail(error instanceof Error ? error.message : String(error));
492
+ }
493
+ }
494
+
495
+ function createSearchWorker() {
496
+ if (worker) return;
497
+
498
+ status.value = "indexing";
499
+ try {
500
+ worker = new Worker(
501
+ new URL("../../workers/site-search.worker.ts", import.meta.url),
502
+ { type: "module" },
503
+ );
504
+ worker.addEventListener("message", onWorkerMessage);
505
+ worker.addEventListener("error", (event) =>
506
+ fail(event.message || "Site search worker failed"),
507
+ );
508
+ } catch (error) {
509
+ fail(error instanceof Error ? error.message : String(error));
510
+ return;
511
+ }
512
+
513
+ initRequestId = ++requestId;
514
+ post({
515
+ type: "init",
516
+ requestId: initRequestId,
517
+ options: {
518
+ origin: window.location.origin,
519
+ basePath: runtimeConfig.app.baseURL || "/",
520
+ seedUrls: getSeedUrls(),
521
+ locales: settings.locales.length
522
+ ? [...settings.locales]
523
+ : [activeLocale.value],
524
+ defaultLocale: settings.defaultLocale || activeLocale.value,
525
+ maxPages: props.maxPages,
526
+ maxContentLength: props.maxContentLength,
527
+ concurrency: Math.max(1, props.concurrency),
528
+ cacheTtl: import.meta.dev ? 0 : props.cacheTtl,
529
+ respectNoIndex: import.meta.dev ? false : props.respectNoIndex,
530
+ discoverSitemaps: import.meta.dev ? false : props.discoverSitemaps,
531
+ },
532
+ });
533
+ }
534
+
535
+ function initializeSearch() {
536
+ if (resolvedStrategy.value === "remote") {
537
+ if (!props.endpoint.trim()) {
538
+ fail("Remote search endpoint is not configured");
539
+ return;
540
+ }
541
+
542
+ const testUrl = new URL(props.endpoint, window.location.origin);
543
+ testUrl.searchParams.set("q", "test");
544
+ testUrl.searchParams.set("locale", activeLocale.value);
545
+ testUrl.searchParams.set("operator", "and");
546
+ testUrl.searchParams.set("limit", "1");
547
+ testUrl.searchParams.set("allLocales", "false");
548
+
549
+ status.value = "indexing";
550
+ errorMessage.value = "";
551
+
552
+ const timeoutController = new AbortController();
553
+ const timeoutId = setTimeout(() => timeoutController.abort(), 5000);
554
+
555
+ fetch(testUrl, {
556
+ headers: { Accept: "application/json" },
557
+ credentials: "same-origin",
558
+ signal: timeoutController.signal,
559
+ })
560
+ .then((response) => {
561
+ clearTimeout(timeoutId);
562
+ if (!response.ok) {
563
+ throw new Error(
564
+ `Search endpoint returned ${response.status}`,
565
+ );
566
+ }
567
+ return response.json();
568
+ })
569
+ .then((data) => {
570
+ if (!data || typeof data !== "object") {
571
+ throw new Error("Invalid search endpoint response");
572
+ }
573
+ status.value = "ready";
574
+ emit("ready", 0);
575
+ })
576
+ .catch((error) => {
577
+ clearTimeout(timeoutId);
578
+ if (error.name === "AbortError") {
579
+ fail(
580
+ "Search endpoint timeout. Please check the endpoint URL and try again.",
581
+ );
582
+ } else {
583
+ fail(
584
+ error instanceof Error ? error.message : String(error),
585
+ );
586
+ }
587
+ });
588
+ return;
589
+ }
590
+ createSearchWorker();
591
+ }
592
+
593
+ function onWorkerMessage(event: MessageEvent<SiteSearchWorkerResponse>) {
594
+ const message = event.data;
595
+ if (message.type === "progress") {
596
+ indexedPages.value = message.indexed;
597
+ totalPages.value = message.total;
598
+ return;
599
+ }
600
+ if (message.type === "ready" && message.requestId === initRequestId) {
601
+ status.value = "ready";
602
+ indexedCount.value = message.count;
603
+ emit("ready", message.count);
604
+ queueSearch(0);
605
+ return;
606
+ }
607
+ if (
608
+ message.type === "results" &&
609
+ message.requestId === latestSearchRequestId
610
+ ) {
611
+ results.value = message.results.slice(0, requestedResultLimit.value);
612
+ selectedIndex.value = 0;
613
+ searching.value = false;
614
+ return;
615
+ }
616
+ if (message.type === "error") fail(message.message);
617
+ }
618
+
619
+ function fail(message: string) {
620
+ status.value = "error";
621
+ searching.value = false;
622
+ errorMessage.value = message;
623
+ emit("error", message);
624
+ }
625
+
626
+ function runSearch() {
627
+ const normalized = query.value.trim();
628
+ if (
629
+ !searchArmed.value ||
630
+ status.value !== "ready" ||
631
+ normalized.length < props.minQueryLength
632
+ )
633
+ return;
634
+ searching.value = true;
635
+ latestSearchRequestId = ++requestId;
636
+ if (resolvedStrategy.value === "remote") {
637
+ void runRemoteSearch(latestSearchRequestId, normalized);
638
+ return;
639
+ }
640
+ post({
641
+ type: "search",
642
+ requestId: latestSearchRequestId,
643
+ query: normalized,
644
+ locale: activeLocale.value,
645
+ allLocales: props.searchAllLocales,
646
+ operator: activeOperator.value,
647
+ limit: requestedResultLimit.value,
648
+ });
649
+ }
650
+
651
+ function normalizedLocale(value: string) {
652
+ return value.trim().toLowerCase().replace("_", "-").split("-")[0] || "en";
653
+ }
654
+
655
+ function localeFromPath(path: string) {
656
+ const pathname = new URL(path, window.location.origin).pathname;
657
+ const firstSegment = pathname.split("/").filter(Boolean)[0] ?? "";
658
+ const matched = settings.locales.find(
659
+ (locale) => normalizedLocale(locale) === normalizedLocale(firstSegment),
660
+ );
661
+ return normalizedLocale(
662
+ matched || settings.defaultLocale || activeLocale.value,
663
+ );
664
+ }
665
+
666
+ function translationKey(path: string) {
667
+ const pathname = new URL(path, window.location.origin).pathname;
668
+ const segments = pathname.split("/").filter(Boolean);
669
+ const knownLocales = new Set(settings.locales.map(normalizedLocale));
670
+ if (segments[0] && knownLocales.has(normalizedLocale(segments[0])))
671
+ segments.shift();
672
+ return `/${segments.join("/")}`.replace(/\/$/, "") || "/";
673
+ }
674
+
675
+ function safeRanges(value: string, ranges: SiteSearchRange[] | undefined) {
676
+ return (ranges ?? [])
677
+ .map(
678
+ ([start, end]) =>
679
+ [
680
+ Math.max(0, start),
681
+ Math.min(value.length - 1, end),
682
+ ] as SiteSearchRange,
683
+ )
684
+ .filter(([start, end]) => start <= end)
685
+ .sort(([left], [right]) => left - right);
686
+ }
687
+
688
+ function directMatches(value: string, searchQuery: string) {
689
+ const source = value.toLocaleLowerCase();
690
+ const words = [
691
+ ...new Set(
692
+ searchQuery.toLocaleLowerCase().split(/\s+/).filter(Boolean),
693
+ ),
694
+ ];
695
+ const ranges: SiteSearchRange[] = [];
696
+ for (const word of words) {
697
+ let offset = 0;
698
+ while (offset < source.length) {
699
+ const start = source.indexOf(word, offset);
700
+ if (start < 0) break;
701
+ ranges.push([start, start + word.length - 1]);
702
+ offset = start + word.length;
703
+ }
704
+ }
705
+ return safeRanges(value, ranges);
706
+ }
707
+
708
+ async function runRemoteSearch(searchRequestId: number, searchQuery: string) {
709
+ remoteController?.abort();
710
+ remoteController = new AbortController();
711
+
712
+ try {
713
+ const requestUrl = new URL(props.endpoint, window.location.origin);
714
+ requestUrl.searchParams.set("q", searchQuery);
715
+ requestUrl.searchParams.set("locale", activeLocale.value);
716
+ requestUrl.searchParams.set("operator", activeOperator.value);
717
+ requestUrl.searchParams.set(
718
+ "limit",
719
+ String(requestedResultLimit.value),
720
+ );
721
+ requestUrl.searchParams.set(
722
+ "allLocales",
723
+ String(props.searchAllLocales),
724
+ );
725
+
726
+ const response = await fetch(requestUrl, {
727
+ headers: { Accept: "application/json" },
728
+ credentials: "same-origin",
729
+ signal: remoteController.signal,
730
+ });
731
+ if (!response.ok) {
732
+ if (response.status === 404) {
733
+ throw new Error(
734
+ "Search endpoint not found (404). Please check the endpoint URL.",
735
+ );
736
+ } else if (response.status === 500) {
737
+ throw new Error(
738
+ "Search endpoint server error (500). Please try again later.",
739
+ );
740
+ } else {
741
+ throw new Error(`Search endpoint returned ${response.status}`);
742
+ }
743
+ }
744
+ const payload = (await response.json()) as SiteSearchRemoteResponse;
745
+ if (!payload || !Array.isArray(payload.results))
746
+ throw new Error("Invalid search endpoint response");
747
+ if (searchRequestId !== latestSearchRequestId) return;
748
+
749
+ const currentLocale = normalizedLocale(activeLocale.value);
750
+ const normalizedResults = payload.results.map(
751
+ (item, index): SiteSearchResult => {
752
+ const locale = normalizedLocale(
753
+ item.locale || localeFromPath(item.path),
754
+ );
755
+ const description = item.description ?? "";
756
+ const snippet = item.snippet ?? description;
757
+ return {
758
+ id: item.id || item.url || item.path || String(index),
759
+ url:
760
+ item.url ||
761
+ new URL(item.path, window.location.origin).href,
762
+ path: item.path,
763
+ title: item.title,
764
+ description,
765
+ snippet,
766
+ titleMatches: safeRanges(item.title, item.highlights?.title)
767
+ .length
768
+ ? safeRanges(item.title, item.highlights?.title)
769
+ : directMatches(item.title, searchQuery),
770
+ snippetMatches: safeRanges(
771
+ snippet,
772
+ item.highlights?.snippet,
773
+ ).length
774
+ ? safeRanges(snippet, item.highlights?.snippet)
775
+ : directMatches(snippet, searchQuery),
776
+ pathMatches: safeRanges(item.path, item.highlights?.path)
777
+ .length
778
+ ? safeRanges(item.path, item.highlights?.path)
779
+ : directMatches(item.path, searchQuery),
780
+ locale,
781
+ score: item.score ?? 1,
782
+ };
783
+ },
784
+ );
785
+
786
+ normalizedResults.sort((left, right) => {
787
+ const localeDifference =
788
+ Number(normalizedLocale(right.locale) === currentLocale) -
789
+ Number(normalizedLocale(left.locale) === currentLocale);
790
+ return localeDifference || left.score - right.score;
791
+ });
792
+ const seenPages = new Set<string>();
793
+ results.value = normalizedResults
794
+ .filter((result) => {
795
+ const key = translationKey(result.path);
796
+ if (seenPages.has(key)) return false;
797
+ seenPages.add(key);
798
+ return true;
799
+ })
800
+ .slice(0, requestedResultLimit.value);
801
+ selectedIndex.value = 0;
802
+ searching.value = false;
803
+ } catch (error) {
804
+ if (error instanceof DOMException && error.name === "AbortError")
805
+ return;
806
+ if (searchRequestId !== latestSearchRequestId) return;
807
+
808
+ let errorMessage = "Search could not be prepared. Please try again.";
809
+ if (error instanceof Error) {
810
+ if (error.message.includes("404")) {
811
+ errorMessage =
812
+ "Search endpoint not found. Please check the endpoint URL.";
813
+ } else if (error.message.includes("Failed to fetch")) {
814
+ errorMessage =
815
+ "Network error. Please check your connection and try again.";
816
+ } else {
817
+ errorMessage = error.message;
818
+ }
819
+ }
820
+ fail(errorMessage);
821
+ }
822
+ }
823
+
824
+ function queueSearch(delay = props.debounce) {
825
+ if (searchTimer) clearTimeout(searchTimer);
826
+ if (
827
+ !searchArmed.value ||
828
+ query.value.trim().length < props.minQueryLength
829
+ ) {
830
+ results.value = [];
831
+ searching.value = false;
832
+ return;
833
+ }
834
+
835
+ if (status.value !== "ready") return;
836
+ searchTimer = setTimeout(runSearch, Math.max(0, delay));
837
+ }
838
+
839
+ function activateSearch() {
840
+ searchArmed.value = true;
841
+ }
842
+
843
+ function changeOperator() {
844
+ emit("update:operator", activeOperator.value);
845
+ searchArmed.value = true;
846
+ queueSearch(0);
847
+ }
848
+
849
+ function highlightedSegments(value: string, ranges: [number, number][]) {
850
+ if (!ranges.length) return [{ text: value, highlighted: false }];
851
+ const segments: { text: string; highlighted: boolean }[] = [];
852
+ let cursor = 0;
853
+ for (const [rawStart, rawEnd] of ranges) {
854
+ const start = Math.max(cursor, rawStart);
855
+ const end = Math.min(value.length - 1, rawEnd);
856
+ if (start > end) continue;
857
+ if (start > cursor)
858
+ segments.push({
859
+ text: value.slice(cursor, start),
860
+ highlighted: false,
861
+ });
862
+ segments.push({ text: value.slice(start, end + 1), highlighted: true });
863
+ cursor = end + 1;
864
+ }
865
+ if (cursor < value.length)
866
+ segments.push({ text: value.slice(cursor), highlighted: false });
867
+ return segments;
868
+ }
869
+
870
+ function resultTarget(result: SiteSearchResult) {
871
+ if (!result.url) return result.path;
872
+ const url = new URL(result.url, window.location.origin);
873
+ return url.origin === window.location.origin ? result.path : url.href;
874
+ }
875
+
876
+ async function open(preset = query.value, immediate = true) {
877
+ query.value = preset.trim();
878
+ searchArmed.value = immediate;
879
+ results.value = [];
880
+ setOpen(true);
881
+ initializeSearch();
882
+ await nextTick();
883
+ searchInput.value?.focus();
884
+ if (immediate) {
885
+ if (status.value !== "ready") {
886
+ const unwatch = watch(status, (newStatus) => {
887
+ if (newStatus === "ready") {
888
+ unwatch();
889
+ queueSearch(0);
890
+ } else if (newStatus === "error") {
891
+ unwatch();
892
+ }
893
+ });
894
+ } else {
895
+ queueSearch(0);
896
+ }
897
+ }
898
+ }
899
+
900
+ function close() {
901
+ closeSearch(true);
902
+ }
903
+
904
+ function resetSearch() {
905
+ if (searchTimer) clearTimeout(searchTimer);
906
+ remoteController?.abort();
907
+ latestSearchRequestId = ++requestId;
908
+ query.value = "";
909
+ results.value = [];
910
+ selectedIndex.value = 0;
911
+ searching.value = false;
912
+ searchArmed.value = true;
913
+ activeOperator.value = props.operator;
914
+ }
915
+
916
+ function clearSearchUrl() {
917
+ if (
918
+ route.query[props.queryParam] === undefined &&
919
+ route.query[props.modeParam] === undefined &&
920
+ route.query[props.operatorParam] === undefined
921
+ )
922
+ return;
923
+
924
+ const nextQuery = { ...route.query };
925
+ delete nextQuery[props.queryParam];
926
+ delete nextQuery[props.modeParam];
927
+ delete nextQuery[props.operatorParam];
928
+ void router.replace({
929
+ path: route.path,
930
+ query: nextQuery,
931
+ hash: route.hash,
932
+ });
933
+ }
934
+
935
+ function closeSearch(clearUrl: boolean) {
936
+ internalCloseHandled = true;
937
+ setOpen(false);
938
+ resetSearch();
939
+ if (clearUrl) clearSearchUrl();
940
+ queueMicrotask(() => {
941
+ if (isOpen.value) internalCloseHandled = false;
942
+ });
943
+ }
944
+
945
+ async function clearQuery() {
946
+ query.value = "";
947
+ results.value = [];
948
+ selectedIndex.value = 0;
949
+ searching.value = false;
950
+ searchArmed.value = true;
951
+ await nextTick();
952
+ searchInput.value?.focus();
953
+ }
954
+
955
+ function search(value = query.value) {
956
+ query.value = value.trim();
957
+ searchArmed.value = true;
958
+ queueSearch(0);
959
+ }
960
+
961
+ function clearCache() {
962
+ if (resolvedStrategy.value === "remote") return;
963
+ createSearchWorker();
964
+ post({ type: "clear-cache", requestId: ++requestId });
965
+ }
966
+
967
+ function moveSelection(direction: number) {
968
+ if (!results.value.length) return;
969
+ selectedIndex.value =
970
+ (selectedIndex.value + direction + results.value.length) %
971
+ results.value.length;
972
+ nextTick(() => {
973
+ document
974
+ .querySelector<HTMLElement>(
975
+ `[data-site-search-index="${selectedIndex.value}"]`,
976
+ )
977
+ ?.scrollIntoView({ block: "nearest" });
978
+ });
979
+ }
980
+
981
+ function onEnter() {
982
+ if (!searchArmed.value) {
983
+ searchArmed.value = true;
984
+ queueSearch(0);
985
+ return;
986
+ }
987
+ const result = results.value[selectedIndex.value];
988
+ if (result) {
989
+ selectResult(result);
990
+ const target = result.url || result.path;
991
+ const targetUrl = new URL(target, window.location.origin);
992
+ if (targetUrl.origin === window.location.origin)
993
+ void router.push(
994
+ `${targetUrl.pathname}${targetUrl.search}${targetUrl.hash}`,
995
+ );
996
+ else window.location.assign(targetUrl.href);
997
+ } else queueSearch(0);
998
+ }
999
+
1000
+ function selectResult(result: SiteSearchResult) {
1001
+ emit("select", result);
1002
+ closeSearch(false);
1003
+ }
1004
+
1005
+ function queryValue(value: unknown) {
1006
+ return Array.isArray(value)
1007
+ ? String(value[0] ?? "")
1008
+ : typeof value === "string"
1009
+ ? value
1010
+ : "";
1011
+ }
1012
+
1013
+ async function handleUrlQuery() {
1014
+ if (props.disableUrlHandling) return;
1015
+
1016
+ const preset = queryValue(route.query[props.queryParam]).trim();
1017
+ if (!preset) {
1018
+ lastUrlSignature = "";
1019
+ return;
1020
+ }
1021
+ const rawMode = queryValue(route.query[props.modeParam]);
1022
+ const mode: UrlMode =
1023
+ rawMode === "prefill" || rawMode === "search" ? rawMode : props.urlMode;
1024
+ const rawOperator = queryValue(
1025
+ route.query[props.operatorParam],
1026
+ ).toLowerCase();
1027
+ activeOperator.value =
1028
+ rawOperator === "and" || rawOperator === "or"
1029
+ ? rawOperator
1030
+ : props.operator;
1031
+ const signature = `${route.fullPath}\u0000${mode}\u0000${activeOperator.value}\u0000${preset}`;
1032
+ if (signature === lastUrlSignature) return;
1033
+ lastUrlSignature = signature;
1034
+ await open(preset, mode === "search");
1035
+
1036
+ if (props.consumeUrl) {
1037
+ const nextQuery = { ...route.query };
1038
+ delete nextQuery[props.queryParam];
1039
+ delete nextQuery[props.modeParam];
1040
+ delete nextQuery[props.operatorParam];
1041
+ await router.replace({
1042
+ path: route.path,
1043
+ query: nextQuery,
1044
+ hash: route.hash,
1045
+ });
1046
+ }
1047
+ }
1048
+
1049
+ function onGlobalKeydown(event: KeyboardEvent) {
1050
+ if (event.key === "Escape" && isOpen.value) {
1051
+ close();
1052
+ return;
1053
+ }
1054
+ if (!props.shortcut || !(event.ctrlKey || event.metaKey)) return;
1055
+ if (
1056
+ event.shiftKey !== props.shortcutShift ||
1057
+ event.key.toLowerCase() !== props.shortcutKey.toLowerCase()
1058
+ )
1059
+ return;
1060
+ event.preventDefault();
1061
+ isOpen.value ? close() : open("", true);
1062
+ }
1063
+
1064
+ watch(query, () => queueSearch());
1065
+ watch(
1066
+ () => props.operator,
1067
+ (value) => {
1068
+ activeOperator.value = value;
1069
+ queueSearch(0);
1070
+ },
1071
+ );
1072
+ watch(resolvedStrategy, () => {
1073
+ remoteController?.abort();
1074
+ worker?.terminate();
1075
+ worker = undefined;
1076
+ status.value = "idle";
1077
+ indexedCount.value = 0;
1078
+ if (isOpen.value) {
1079
+ initializeSearch();
1080
+ queueSearch(0);
1081
+ }
1082
+ });
1083
+ watch(
1084
+ () => [
1085
+ route.query[props.queryParam],
1086
+ route.query[props.modeParam],
1087
+ route.query[props.operatorParam],
1088
+ ],
1089
+ () => {
1090
+ if (!props.disableUrlHandling) {
1091
+ handleUrlQuery();
1092
+ }
1093
+ },
1094
+ );
1095
+ watch(isOpen, async (value, previousValue) => {
1096
+ if (!value) {
1097
+ if (!previousValue) return;
1098
+ if (internalCloseHandled) {
1099
+ internalCloseHandled = false;
1100
+ return;
1101
+ }
1102
+ resetSearch();
1103
+ clearSearchUrl();
1104
+ return;
1105
+ }
1106
+
1107
+ if (previousValue === false && !internalCloseHandled) {
1108
+ window.dispatchEvent(new CustomEvent(UI_SITE_SEARCH_OPEN_EVENT));
1109
+ initializeSearch();
1110
+ await nextTick();
1111
+ searchInput.value?.focus();
1112
+ }
1113
+ });
1114
+
1115
+ function onSiteSearchOpen(event: Event) {
1116
+ if (isOpen.value) return;
1117
+ const customEvent = event as CustomEvent;
1118
+ const { preset = "", immediate = true } = customEvent.detail || {};
1119
+ open(preset, immediate);
1120
+ }
1121
+
1122
+ function onSiteSearchClose() {
1123
+ close();
1124
+ }
1125
+
1126
+ onMounted(() => {
1127
+ mounted.value = true;
1128
+ window.addEventListener("keydown", onGlobalKeydown);
1129
+ window.addEventListener(
1130
+ UI_SITE_SEARCH_OPEN_EVENT,
1131
+ onSiteSearchOpen as EventListener,
1132
+ );
1133
+ window.addEventListener("myelophone:site-search-close", onSiteSearchClose);
1134
+
1135
+ if (!props.disableUrlHandling) {
1136
+ handleUrlQuery();
1137
+ }
1138
+ });
1139
+
1140
+ onBeforeUnmount(() => {
1141
+ window.removeEventListener("keydown", onGlobalKeydown);
1142
+ window.removeEventListener(
1143
+ UI_SITE_SEARCH_OPEN_EVENT,
1144
+ onSiteSearchOpen as EventListener,
1145
+ );
1146
+ window.removeEventListener(
1147
+ "myelophone:site-search-close",
1148
+ onSiteSearchClose,
1149
+ );
1150
+ if (searchTimer) clearTimeout(searchTimer);
1151
+ remoteController?.abort();
1152
+ worker?.terminate();
1153
+ });
1154
+
1155
+ defineExpose({ open, close, search, clearCache });
1156
+ </script>
1157
+
1158
+ <style scoped>
1159
+ .site-search-input {
1160
+ appearance: textfield;
1161
+ }
1162
+
1163
+ .site-search-input::-webkit-search-cancel-button,
1164
+ .site-search-input::-webkit-search-decoration,
1165
+ .site-search-input::-webkit-search-results-button,
1166
+ .site-search-input::-webkit-search-results-decoration {
1167
+ display: none;
1168
+ appearance: none;
1169
+ }
1170
+
1171
+ .site-search-input::-ms-clear,
1172
+ .site-search-input::-ms-reveal {
1173
+ display: none;
1174
+ width: 0;
1175
+ height: 0;
1176
+ }
1177
+
1178
+ .site-search-results {
1179
+ max-height: min(calc(7rem * 3), calc(100dvh - 12rem));
1180
+ overflow-y: auto;
1181
+ overscroll-behavior: contain;
1182
+ scrollbar-gutter: stable;
1183
+ }
1184
+
1185
+ .site-search-mark {
1186
+ border-radius: 0.2em;
1187
+ padding-inline: 0.08em;
1188
+ background: color-mix(in srgb, var(--ui-primary, #f59e0b) 32%, transparent);
1189
+ color: inherit;
1190
+ }
1191
+ </style>