@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,661 @@
1
+ /// <reference lib="webworker" />
2
+
3
+ import Fuse from 'fuse.js';
4
+ import type {
5
+ SiteSearchDocument,
6
+ SiteSearchResult,
7
+ SiteSearchOperator,
8
+ SiteSearchWorkerOptions,
9
+ SiteSearchWorkerRequest,
10
+ SiteSearchWorkerResponse,
11
+ } from '~/types/site-search';
12
+
13
+ interface CachedIndex {
14
+ key: string;
15
+ updatedAt: number;
16
+ signature: string;
17
+ documents: SiteSearchDocument[];
18
+ }
19
+
20
+ const workerScope = self as DedicatedWorkerGlobalScope;
21
+ const databaseName = 'myelophone-site-search';
22
+ const databaseVersion = 1;
23
+ const storeName = 'indexes';
24
+ const excludedExtensions =
25
+ /\.(?:avif|bmp|css|csv|docx?|gif|ico|jpe?g|js|json|map|mp3|mp4|ogg|pdf|png|pptx?|svg|txt|webm|webp|woff2?|xlsx?|xml|zip)$/i;
26
+
27
+ let documents: SiteSearchDocument[] = [];
28
+ const fuseByLocale = new Map<string, Fuse<SiteSearchDocument>>();
29
+
30
+ const post = (message: SiteSearchWorkerResponse) =>
31
+ workerScope.postMessage(message);
32
+
33
+ const normalizeLocale = (locale: string) =>
34
+ locale.trim().toLowerCase().replace('_', '-').split('-')[0] || 'en';
35
+
36
+ const decodeEntities = (value: string) => {
37
+ const named: Record<string, string> = {
38
+ amp: '&',
39
+ apos: "'",
40
+ gt: '>',
41
+ lt: '<',
42
+ nbsp: ' ',
43
+ quot: '"',
44
+ };
45
+
46
+ return value.replace(/&(#x?[\da-f]+|[a-z]+);/gi, (entity, code: string) => {
47
+ if (code[0] !== '#') return named[code.toLowerCase()] ?? entity;
48
+ const isHex = code[1]?.toLowerCase() === 'x';
49
+ const point = Number.parseInt(
50
+ code.slice(isHex ? 2 : 1),
51
+ isHex ? 16 : 10,
52
+ );
53
+ return Number.isFinite(point) ? String.fromCodePoint(point) : entity;
54
+ });
55
+ };
56
+
57
+ const textFromHtml = (value: string) =>
58
+ decodeEntities(
59
+ value
60
+ .replace(/<!--[\s\S]*?-->/g, ' ')
61
+ .replace(
62
+ /<(script|style|noscript|template|svg|header|footer|nav|form|dialog)\b[^>]*>[\s\S]*?<\/\1>/gi,
63
+ ' ',
64
+ )
65
+ .replace(
66
+ /<([a-z][\w-]*)\b[^>]*(?:data-nosnippet|aria-hidden=["']?true)[^>]*>[\s\S]*?<\/\1>/gi,
67
+ ' ',
68
+ )
69
+ .replace(/<br\s*\/?\s*>/gi, ' ')
70
+ .replace(/<[^>]+>/g, ' '),
71
+ )
72
+ .replace(/\s+/g, ' ')
73
+ .trim();
74
+
75
+ const getAttribute = (tag: string, name: string) => {
76
+ const match = tag.match(
77
+ new RegExp(`\\b${name}\\s*=\\s*(?:["']([^"']*)["']|([^\\s>]+))`, 'i'),
78
+ );
79
+ return match?.[1] ?? match?.[2] ?? '';
80
+ };
81
+
82
+ const getMetaContent = (html: string, name: string) => {
83
+ for (const match of html.matchAll(/<meta\b[^>]*>/gi)) {
84
+ const tag = match[0];
85
+ const key = getAttribute(tag, 'name') || getAttribute(tag, 'property');
86
+ if (key.toLowerCase() === name.toLowerCase())
87
+ return decodeEntities(getAttribute(tag, 'content')).trim();
88
+ }
89
+ return '';
90
+ };
91
+
92
+ const inferLocale = (url: URL, options: SiteSearchWorkerOptions) => {
93
+ const firstSegment =
94
+ url.pathname.split('/').filter(Boolean)[0]?.toLowerCase() ?? '';
95
+ const matched = options.locales.find(
96
+ (locale) => normalizeLocale(locale) === normalizeLocale(firstSegment),
97
+ );
98
+ return normalizeLocale(matched ?? options.defaultLocale);
99
+ };
100
+
101
+ const extractDocument = (
102
+ html: string,
103
+ sourceUrl: string,
104
+ options: SiteSearchWorkerOptions,
105
+ ) => {
106
+ const robots = getMetaContent(html, 'robots').toLowerCase();
107
+ if (options.respectNoIndex && /\bnoindex\b/.test(robots)) return null;
108
+
109
+ const url = new URL(sourceUrl);
110
+ const titleHtml =
111
+ html.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? '';
112
+ const h1Html = html.match(/<h1\b[^>]*>([\s\S]*?)<\/h1>/i)?.[1] ?? '';
113
+ const title =
114
+ textFromHtml(titleHtml) || textFromHtml(h1Html) || url.pathname;
115
+ const description = getMetaContent(html, 'description');
116
+ const mainHtml =
117
+ html.match(/<main\b[^>]*>([\s\S]*?)<\/main>/i)?.[1] ??
118
+ html.match(/<body\b[^>]*>([\s\S]*?)<\/body>/i)?.[1] ??
119
+ html;
120
+ const headings = [
121
+ ...mainHtml.matchAll(/<h[1-3]\b[^>]*>([\s\S]*?)<\/h[1-3]>/gi),
122
+ ]
123
+ .map((match) => textFromHtml(match[1] ?? ''))
124
+ .filter(Boolean)
125
+ .join(' ');
126
+ const content = textFromHtml(mainHtml).slice(0, options.maxContentLength);
127
+ if (!content && !title && !description) return null;
128
+
129
+ url.hash = '';
130
+ return {
131
+ id: url.href,
132
+ url: url.href,
133
+ path: `${url.pathname}${url.search}`,
134
+ title,
135
+ description,
136
+ headings,
137
+ content,
138
+ locale: inferLocale(url, options),
139
+ } satisfies SiteSearchDocument;
140
+ };
141
+
142
+ const isIndexableUrl = (value: string, origin: string) => {
143
+ try {
144
+ const url = new URL(value, origin);
145
+ return (
146
+ url.origin === origin &&
147
+ (url.protocol === 'http:' || url.protocol === 'https:') &&
148
+ !excludedExtensions.test(url.pathname) &&
149
+ !url.pathname.startsWith('/api/') &&
150
+ !url.pathname.startsWith('/_')
151
+ );
152
+ } catch {
153
+ return false;
154
+ }
155
+ };
156
+
157
+ const extractPageLinks = (html: string, sourceUrl: string, origin: string) => {
158
+ const links: string[] = [];
159
+ for (const match of html.matchAll(/<a\b[^>]*>/gi)) {
160
+ const tag = match[0];
161
+ if (/\brel\s*=\s*["'][^"']*\bnofollow\b/i.test(tag)) continue;
162
+ const href = getAttribute(tag, 'href');
163
+ if (!href) continue;
164
+
165
+ try {
166
+ const url = new URL(href, sourceUrl);
167
+ url.hash = '';
168
+ if (!url.search && isIndexableUrl(url.href, origin))
169
+ links.push(url.href);
170
+ } catch {}
171
+ }
172
+ return links;
173
+ };
174
+
175
+ const fetchText = async (url: string, accept: string) => {
176
+ const response = await fetch(url, {
177
+ headers: { Accept: accept },
178
+ credentials: 'same-origin',
179
+ });
180
+ if (!response.ok) throw new Error(`${response.status} ${url}`);
181
+ return {
182
+ text: await response.text(),
183
+ contentType: response.headers.get('content-type') ?? '',
184
+ };
185
+ };
186
+
187
+ const extractLocations = (xml: string) =>
188
+ [...xml.matchAll(/<loc\b[^>]*>([\s\S]*?)<\/loc>/gi)]
189
+ .map((match) => decodeEntities(match[1] ?? '').trim())
190
+ .filter(Boolean);
191
+
192
+ const discoverUrls = async (options: SiteSearchWorkerOptions) => {
193
+ const pageUrls = new Set<string>();
194
+ const sitemapUrls = new Set<string>();
195
+ const visitedSitemaps = new Set<string>();
196
+ const baseUrl = new URL(options.basePath || '/', options.origin);
197
+
198
+ for (const seed of options.seedUrls) {
199
+ const absolute = new URL(seed, baseUrl);
200
+ absolute.hash = '';
201
+ if (isIndexableUrl(absolute.href, options.origin))
202
+ pageUrls.add(absolute.href);
203
+ }
204
+
205
+ if (!options.discoverSitemaps)
206
+ return [...pageUrls].slice(0, options.maxPages);
207
+
208
+ try {
209
+ const robots = await fetchText(
210
+ new URL('robots.txt', baseUrl).href,
211
+ 'text/plain',
212
+ );
213
+ for (const match of robots.text.matchAll(
214
+ /^\s*Sitemap:\s*(\S+)\s*$/gim,
215
+ )) {
216
+ if (match[1]) sitemapUrls.add(new URL(match[1], baseUrl).href);
217
+ }
218
+ } catch {}
219
+
220
+ sitemapUrls.add(new URL('sitemap.xml', baseUrl).href);
221
+ // TODO: add sitemap_index with multiple sitemaps
222
+ // sitemapUrls.add(new URL('sitemap_index.xml', baseUrl).href);
223
+
224
+ const crawlSitemap = async (
225
+ sitemapUrl: string,
226
+ depth = 0,
227
+ ): Promise<void> => {
228
+ if (depth > 3 || visitedSitemaps.has(sitemapUrl)) return;
229
+ visitedSitemaps.add(sitemapUrl);
230
+
231
+ try {
232
+ const response = await fetchText(
233
+ sitemapUrl,
234
+ 'application/xml,text/xml;q=0.9',
235
+ );
236
+ const xml = response.text;
237
+ if (
238
+ !response.contentType.includes('xml') &&
239
+ !/^\s*<\?xml|<urlset|<sitemapindex/i.test(xml)
240
+ )
241
+ return;
242
+
243
+ const locations = extractLocations(xml);
244
+ if (/<sitemapindex\b/i.test(xml)) {
245
+ await Promise.all(
246
+ locations.map((location) =>
247
+ crawlSitemap(
248
+ new URL(location, sitemapUrl).href,
249
+ depth + 1,
250
+ ),
251
+ ),
252
+ );
253
+ return;
254
+ }
255
+
256
+ for (const location of locations) {
257
+ const url = new URL(location, sitemapUrl);
258
+ url.hash = '';
259
+ if (isIndexableUrl(url.href, options.origin))
260
+ pageUrls.add(url.href);
261
+ }
262
+ } catch {}
263
+ };
264
+
265
+ await Promise.all([...sitemapUrls].map((url) => crawlSitemap(url)));
266
+ return [...pageUrls].slice(0, options.maxPages);
267
+ };
268
+
269
+ const openDatabase = () =>
270
+ new Promise<IDBDatabase>((resolve, reject) => {
271
+ const request = indexedDB.open(databaseName, databaseVersion);
272
+ request.onupgradeneeded = () => {
273
+ const database = request.result;
274
+ if (!database.objectStoreNames.contains(storeName))
275
+ database.createObjectStore(storeName, { keyPath: 'key' });
276
+ };
277
+ request.onsuccess = () => resolve(request.result);
278
+ request.onerror = () => reject(request.error);
279
+ });
280
+
281
+ const readCache = async (key: string) => {
282
+ try {
283
+ const database = await openDatabase();
284
+ return await new Promise<CachedIndex | undefined>((resolve, reject) => {
285
+ const request = database
286
+ .transaction(storeName, 'readonly')
287
+ .objectStore(storeName)
288
+ .get(key);
289
+ request.onsuccess = () =>
290
+ resolve(request.result as CachedIndex | undefined);
291
+ request.onerror = () => reject(request.error);
292
+ });
293
+ } catch {
294
+ return undefined;
295
+ }
296
+ };
297
+
298
+ const writeCache = async (record: CachedIndex) => {
299
+ try {
300
+ const database = await openDatabase();
301
+ await new Promise<void>((resolve, reject) => {
302
+ const request = database
303
+ .transaction(storeName, 'readwrite')
304
+ .objectStore(storeName)
305
+ .put(record);
306
+ request.onsuccess = () => resolve();
307
+ request.onerror = () => reject(request.error);
308
+ });
309
+ } catch {}
310
+ };
311
+
312
+ const clearCache = async () => {
313
+ try {
314
+ const database = await openDatabase();
315
+ await new Promise<void>((resolve, reject) => {
316
+ const request = database
317
+ .transaction(storeName, 'readwrite')
318
+ .objectStore(storeName)
319
+ .clear();
320
+ request.onsuccess = () => resolve();
321
+ request.onerror = () => reject(request.error);
322
+ });
323
+ } catch {}
324
+ };
325
+
326
+ const signatureFor = (urls: string[]) => urls.slice().sort().join('\n');
327
+
328
+ const fetchDocuments = async (
329
+ urls: string[],
330
+ options: SiteSearchWorkerOptions,
331
+ ) => {
332
+ const result: SiteSearchDocument[] = [];
333
+ const queue = [...urls];
334
+ const queued = new Set(queue);
335
+ let cursor = 0;
336
+ let processed = 0;
337
+
338
+ const consume = async () => {
339
+ while (cursor < queue.length) {
340
+ const index = cursor++;
341
+ const url = queue[index];
342
+ if (!url) continue;
343
+
344
+ try {
345
+ const response = await fetchText(
346
+ url,
347
+ 'text/html,application/xhtml+xml;q=0.9',
348
+ );
349
+ if (
350
+ response.contentType &&
351
+ !response.contentType.includes('html')
352
+ )
353
+ continue;
354
+ const document = extractDocument(response.text, url, options);
355
+ if (document) result.push(document);
356
+
357
+ for (const link of extractPageLinks(
358
+ response.text,
359
+ url,
360
+ options.origin,
361
+ )) {
362
+ if (queue.length >= options.maxPages) break;
363
+ if (queued.has(link)) continue;
364
+ queued.add(link);
365
+ queue.push(link);
366
+ }
367
+ } catch {
368
+ } finally {
369
+ processed++;
370
+ if (processed === queue.length || processed % 4 === 0) {
371
+ post({
372
+ type: 'progress',
373
+ indexed: processed,
374
+ total: queue.length,
375
+ });
376
+ }
377
+ }
378
+ }
379
+ };
380
+
381
+ await Promise.all(
382
+ Array.from(
383
+ { length: Math.min(options.concurrency, queue.length) },
384
+ consume,
385
+ ),
386
+ );
387
+ return result;
388
+ };
389
+
390
+ const createFuse = (
391
+ items: SiteSearchDocument[],
392
+ operator: SiteSearchOperator,
393
+ ) =>
394
+ new Fuse(items, {
395
+ keys: [
396
+ { name: 'title', weight: 4 },
397
+ { name: 'headings', weight: 2.5 },
398
+ { name: 'description', weight: 2 },
399
+ { name: 'content', weight: 1 },
400
+ { name: 'path', weight: 0.35 },
401
+ ],
402
+ includeMatches: true,
403
+ includeScore: true,
404
+ ignoreDiacritics: true,
405
+ ignoreLocation: true,
406
+ minMatchCharLength: 2,
407
+ threshold: 0.34,
408
+ useTokenSearch: true,
409
+ tokenMatch: operator === 'and' ? 'all' : 'any',
410
+ });
411
+
412
+ const getFuse = (
413
+ locale: string,
414
+ allLocales: boolean,
415
+ operator: SiteSearchOperator,
416
+ ) => {
417
+ const localeKey = allLocales ? '*' : normalizeLocale(locale);
418
+ const key = `${localeKey}:${operator}`;
419
+ const existing = fuseByLocale.get(key);
420
+ if (existing) return existing;
421
+ const source =
422
+ localeKey === '*'
423
+ ? documents
424
+ : documents.filter(
425
+ (document) =>
426
+ normalizeLocale(document.locale) === localeKey,
427
+ );
428
+ const fuse = createFuse(source, operator);
429
+ fuseByLocale.set(key, fuse);
430
+ return fuse;
431
+ };
432
+
433
+ interface FuseMatchData {
434
+ key?: string;
435
+ value?: string;
436
+ indices: readonly (readonly [number, number])[];
437
+ }
438
+
439
+ const normalizeRanges = (
440
+ ranges: readonly (readonly [number, number])[],
441
+ length: number,
442
+ ) =>
443
+ ranges
444
+ .map(
445
+ ([start, end]) =>
446
+ [Math.max(0, start), Math.min(length - 1, end)] as [
447
+ number,
448
+ number,
449
+ ],
450
+ )
451
+ .filter(([start, end]) => start <= end)
452
+ .sort(([left], [right]) => left - right);
453
+
454
+ const rangesFor = (
455
+ matches: readonly FuseMatchData[] | undefined,
456
+ key: string,
457
+ ) =>
458
+ normalizeRanges(
459
+ (matches ?? [])
460
+ .filter((match) => match.key === key)
461
+ .flatMap((match) => match.indices),
462
+ (matches ?? []).find((match) => match.key === key)?.value?.length ?? 0,
463
+ );
464
+
465
+ const snippetFor = (
466
+ document: SiteSearchDocument,
467
+ query: string,
468
+ matches: readonly FuseMatchData[] | undefined,
469
+ ) => {
470
+ const matchedField = (['content', 'description', 'headings'] as const)
471
+ .map((key) => ({
472
+ key,
473
+ match: matches?.find((item) => item.key === key),
474
+ }))
475
+ .find((item) => item.match);
476
+
477
+ if (matchedField?.match) {
478
+ const source = document[matchedField.key];
479
+ const sourceRanges = normalizeRanges(
480
+ matchedField.match.indices,
481
+ source.length,
482
+ );
483
+ const firstStart = sourceRanges[0]?.[0] ?? 0;
484
+ const contextStart = Math.max(0, firstStart - 80);
485
+ const contextEnd = Math.min(source.length, firstStart + 180);
486
+ const prefix = contextStart > 0 ? '…' : '';
487
+ const suffix = contextEnd < source.length ? '…' : '';
488
+ const text = `${prefix}${source.slice(contextStart, contextEnd)}${suffix}`;
489
+ const snippetMatches = sourceRanges
490
+ .filter(([start, end]) => end >= contextStart && start < contextEnd)
491
+ .map(
492
+ ([start, end]) =>
493
+ [
494
+ Math.max(start, contextStart) -
495
+ contextStart +
496
+ prefix.length,
497
+ Math.min(end, contextEnd - 1) -
498
+ contextStart +
499
+ prefix.length,
500
+ ] as [number, number],
501
+ );
502
+ return { text, matches: snippetMatches };
503
+ }
504
+
505
+ const normalizedQuery = query.trim().toLocaleLowerCase();
506
+ const contentLower = document.content.toLocaleLowerCase();
507
+ const words = normalizedQuery
508
+ .split(/\s+/)
509
+ .filter((word) => word.length > 1);
510
+ const positions = words
511
+ .map((word) => contentLower.indexOf(word))
512
+ .filter((position) => position >= 0);
513
+ const position = positions.length ? Math.min(...positions) : 0;
514
+ const start = Math.max(0, position - 80);
515
+ const end = Math.min(
516
+ document.content.length,
517
+ position + Math.max(normalizedQuery.length, 24) + 130,
518
+ );
519
+ return {
520
+ text: `${start > 0 ? '…' : ''}${document.content.slice(start, end).trim()}${end < document.content.length ? '…' : ''}`,
521
+ matches: [] as [number, number][],
522
+ };
523
+ };
524
+
525
+ const initialize = async (
526
+ requestId: number,
527
+ options: SiteSearchWorkerOptions,
528
+ ) => {
529
+ const urls = await discoverUrls(options);
530
+ const signature = signatureFor(urls);
531
+ const cacheKey = `${options.origin}${options.basePath}`;
532
+ const cached = options.cacheTtl > 0 ? await readCache(cacheKey) : undefined;
533
+ const cacheIsFresh =
534
+ cached &&
535
+ cached.documents.length > 0 &&
536
+ cached.signature === signature &&
537
+ Date.now() - cached.updatedAt < options.cacheTtl;
538
+
539
+ documents = cacheIsFresh
540
+ ? cached.documents
541
+ : await fetchDocuments(urls, options);
542
+ fuseByLocale.clear();
543
+
544
+ if (!cacheIsFresh && options.cacheTtl > 0 && documents.length > 0) {
545
+ await writeCache({
546
+ key: cacheKey,
547
+ updatedAt: Date.now(),
548
+ signature,
549
+ documents,
550
+ });
551
+ }
552
+
553
+ post({
554
+ type: 'ready',
555
+ requestId,
556
+ count: documents.length,
557
+ cached: Boolean(cacheIsFresh),
558
+ });
559
+ };
560
+
561
+ const search = (
562
+ requestId: number,
563
+ query: string,
564
+ locale: string,
565
+ allLocales: boolean,
566
+ operator: SiteSearchOperator,
567
+ limit: number,
568
+ ) => {
569
+ const fuse = getFuse(locale, allLocales, operator);
570
+ const knownLocales = new Set(
571
+ documents.map((document) => normalizeLocale(document.locale)),
572
+ );
573
+ const candidateLimit = allLocales
574
+ ? Math.min(documents.length, limit * Math.max(2, knownLocales.size))
575
+ : limit;
576
+ const candidates: SiteSearchResult[] = fuse
577
+ .search(query, { limit: candidateLimit })
578
+ .map((result) => {
579
+ const matches = result.matches as
580
+ readonly FuseMatchData[] | undefined;
581
+ const snippet = snippetFor(result.item, query, matches);
582
+ return {
583
+ id: result.item.id,
584
+ url: result.item.url,
585
+ path: result.item.path,
586
+ title: result.item.title,
587
+ description: result.item.description,
588
+ snippet: snippet.text,
589
+ titleMatches: rangesFor(matches, 'title'),
590
+ snippetMatches: snippet.matches,
591
+ pathMatches: rangesFor(matches, 'path'),
592
+ locale: result.item.locale,
593
+ score: result.score ?? 1,
594
+ };
595
+ });
596
+ const currentLocale = normalizeLocale(locale);
597
+ candidates.sort((left, right) => {
598
+ const localeDifference =
599
+ Number(normalizeLocale(right.locale) === currentLocale) -
600
+ Number(normalizeLocale(left.locale) === currentLocale);
601
+ return localeDifference || left.score - right.score;
602
+ });
603
+
604
+ const seenPages = new Set<string>();
605
+ const results = candidates
606
+ .filter((result) => {
607
+ const url = new URL(result.path, 'https://site-search.invalid');
608
+ const segments = url.pathname.split('/').filter(Boolean);
609
+ if (segments[0] && knownLocales.has(normalizeLocale(segments[0])))
610
+ segments.shift();
611
+ const pageKey = `/${segments.join('/')}`.replace(/\/$/, '') || '/';
612
+ if (seenPages.has(pageKey)) return false;
613
+ seenPages.add(pageKey);
614
+ return true;
615
+ })
616
+ .slice(0, limit);
617
+ post({ type: 'results', requestId, results });
618
+ };
619
+
620
+ workerScope.addEventListener(
621
+ 'message',
622
+ (event: MessageEvent<SiteSearchWorkerRequest>) => {
623
+ const request = event.data;
624
+ if (request.type === 'init') {
625
+ initialize(request.requestId, request.options).catch((error) => {
626
+ post({
627
+ type: 'error',
628
+ requestId: request.requestId,
629
+ message:
630
+ error instanceof Error ? error.message : String(error),
631
+ });
632
+ });
633
+ return;
634
+ }
635
+
636
+ if (request.type === 'search') {
637
+ try {
638
+ search(
639
+ request.requestId,
640
+ request.query,
641
+ request.locale,
642
+ request.allLocales,
643
+ request.operator,
644
+ request.limit,
645
+ );
646
+ } catch (error) {
647
+ post({
648
+ type: 'error',
649
+ requestId: request.requestId,
650
+ message:
651
+ error instanceof Error ? error.message : String(error),
652
+ });
653
+ }
654
+ return;
655
+ }
656
+
657
+ clearCache().then(() =>
658
+ post({ type: 'cache-cleared', requestId: request.requestId }),
659
+ );
660
+ },
661
+ );