wineater-bot 4.16.1 → 4.19.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wineater-bot",
3
- "version": "4.16.1",
3
+ "version": "4.19.0",
4
4
  "description": "",
5
5
  "main": "dist/wineater-chatbot.mjs",
6
6
  "module": "src/index.js",
@@ -22,15 +22,13 @@
22
22
  "author": "",
23
23
  "license": "ISC",
24
24
  "dependencies": {
25
- "@openreplay/tracker": "^14.0.4",
26
- "flagsmith": "^7.0.2",
27
- "vite": "^5.2.9",
28
25
  "svelte": "^5.0.0"
29
26
  },
30
27
  "devDependencies": {
31
28
  "@eslint/compat": "^1.4.0",
32
29
  "@eslint/js": "^9.36.0",
33
30
  "@playwright/test": "^1.55.1",
31
+ "@sveltejs/vite-plugin-svelte": "^4.0.0",
34
32
  "@types/node": "^22",
35
33
  "@vitest/browser": "^3.2.4",
36
34
  "eslint": "^9.36.0",
@@ -42,9 +40,9 @@
42
40
  "prettier-plugin-svelte": "^3.4.0",
43
41
  "rollup-plugin-postcss": "^4.0.2",
44
42
  "typescript-eslint": "^8.44.1",
43
+ "vite": "^5.2.9",
45
44
  "vite-plugin-devtools-json": "^1.0.0",
46
45
  "vitest": "^3.2.4",
47
- "vitest-browser-svelte": "^1.1.0",
48
- "@sveltejs/vite-plugin-svelte": "^4.0.0"
46
+ "vitest-browser-svelte": "^1.1.0"
49
47
  }
50
48
  }
package/src/App.svelte CHANGED
@@ -1,19 +1,19 @@
1
1
  <script lang="ts">
2
2
  import { setCookie, setUniqueUserCookie, trackWidgetEvent } from './services/analytics/analytics';
3
- import Tracker from '@openreplay/tracker';
4
3
  import type { RecommendedWine, Wine, WineId } from './services/api/api';
5
- import { wineaterApi, apiVersion } from './services/api/api';
4
+ import { wineaterApi, apiVersion, cacheScope } from './services/api/api';
5
+ import { readCache, writeCache } from './services/cache';
6
6
  import { onMount } from 'svelte';
7
7
  import { WIDGET_EVENTS } from './services/analytics/constants';
8
8
  import Header from './components/Header.svelte';
9
9
  import Footer from './components/Footer.svelte';
10
10
  import Search from './components/Search.svelte';
11
- import Loader from './components/Loader.svelte';
11
+ import SkeletonTiles from './components/SkeletonTiles.svelte';
12
12
  import WineTile from './components/WineTile.svelte';
13
13
  import Action from './components/ui/Action.svelte';
14
14
  import { appStore } from './services/store/app-store.svelte';
15
15
  import { getTranslation, translateTexts } from './services/i18n/i18n';
16
- import { generateInlineStyle } from './services/ui-config/utils';
16
+ import { generateInlineStyle, preconnectFontHosts } from './services/ui-config/utils';
17
17
 
18
18
  function initCookies() {
19
19
  setCookie('chatbotToken', window.wineaterData?.CLIENT_TOKEN || DEMO_TOKEN, 365);
@@ -21,15 +21,20 @@
21
21
  }
22
22
 
23
23
  const DEMO_TOKEN = 'HeUw6IXuhvz6tRuXdPEGEbj3SGQ3Q2YtEc1Z8jd41Kg9LtyPb4Epu0to31SYIadG';
24
- const tracker = new Tracker({ projectKey: 'PA8j9fAi49i6g2QnvpNs', __DISABLE_SECURE_MODE: true });
25
- tracker.start();
26
24
  initCookies();
25
+ preconnectFontHosts();
26
+
27
+ const INSTANT_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
28
+ const instantCacheKey = `${cacheScope}:instant`;
27
29
 
28
30
  const widgetType = window?.wineaterData?.WIDGET_TYPE || 'store';
29
31
  const countryCode = window?.wineaterData?.COUNTRY_CODE || null;
30
32
  const widgetWrapper = window?.wineaterData?.WIDGET_WRAPPER || 'wineater-widget-conteiner';
31
33
 
32
34
  let initialized = $state(false);
35
+ let configFailed = $state(false);
36
+ // Set once the user searches/clicks, so a late instant response never overwrites their results
37
+ let userActed = false;
33
38
  let isLoading = $state(false);
34
39
  let isLoadingMore = $state(false);
35
40
  let results = $state<RecommendedWine[] | Wine[]>([]);
@@ -103,15 +108,30 @@
103
108
  return translatedResults;
104
109
  }
105
110
 
106
- async function loadInstantRecommendations() {
107
- isLoading = true;
111
+ // Stale-while-revalidate: cached wines are painted immediately and the fresh
112
+ // response only refreshes the cache for the next visit, so the grid the
113
+ // visitor is already looking at never swaps under their cursor.
114
+ async function loadInstantRecommendations(configReady: Promise<void>) {
115
+ const cached = readCache<Wine[]>(instantCacheKey, INSTANT_CACHE_MAX_AGE_MS);
116
+ const hasCached = !!cached?.length;
117
+ if (hasCached) {
118
+ results = cached;
119
+ } else {
120
+ isLoading = true;
121
+ }
108
122
  try {
109
- results = await wineaterApi.getInstantRecommendations();
123
+ const fresh = await wineaterApi.getInstantRecommendations();
124
+ if (fresh.length > 0) writeCache(instantCacheKey, fresh);
125
+ if (!hasCached && !userActed) results = fresh;
110
126
  } catch (error) {
111
127
  console.error('[wineater] instant recommendations error:', error);
112
- setSearchError();
128
+ if (!hasCached && !userActed) {
129
+ // the message is translated, which needs the store language
130
+ await configReady;
131
+ setSearchError();
132
+ }
113
133
  } finally {
114
- isLoading = false;
134
+ if (!userActed) isLoading = false;
115
135
  }
116
136
  }
117
137
 
@@ -128,12 +148,27 @@
128
148
 
129
149
  async function handleSearch(event) {
130
150
  event.preventDefault();
151
+ userActed = true;
131
152
 
132
153
  let query = '';
133
154
  try {
134
155
  isLoading = true;
135
156
  clearError();
136
157
 
158
+ query = searchQuery.trim() + ';';
159
+ lastSearchedQuery = query;
160
+ try {
161
+ trackWidgetEvent(WIDGET_EVENTS.SEARCH_PERFORMED, storeId, { query });
162
+ } catch (error) {
163
+ console.error('Error tracking search event:', error);
164
+ }
165
+
166
+ // Запрос уходит сразу, карточки уезжают вверх, пока он в полёте.
167
+ // Пустой catch нужен, чтобы отказ запроса во время анимации не стал
168
+ // unhandled rejection: ошибку обработает await ниже.
169
+ const request = wineaterApi.getWineRecommendationsSearch(query);
170
+ request.catch(() => {});
171
+
137
172
  // шаг 1: плавно поднять карточки
138
173
  await animateClearAll(220);
139
174
 
@@ -143,15 +178,7 @@
143
178
  hasMore = false;
144
179
  seenIds = [];
145
180
 
146
- query = searchQuery.trim() + ';';
147
- lastSearchedQuery = query;
148
- try {
149
- trackWidgetEvent(WIDGET_EVENTS.SEARCH_PERFORMED, storeId, { query });
150
- } catch (error) {
151
- console.error('Error tracking search event:', error);
152
- }
153
-
154
- const searchResults = await wineaterApi.getWineRecommendationsSearch(query);
181
+ const searchResults = await request;
155
182
 
156
183
  if (!searchResults || searchResults.length === 0) {
157
184
  results = [];
@@ -186,6 +213,7 @@
186
213
 
187
214
  async function handleShowMore() {
188
215
  if (isLoadingMore || !hasMore) return;
216
+ userActed = true;
189
217
  // Выдача заменяется целиком, поэтому держим предыдущую под рукой: если
190
218
  // новых вин не нашлось или запрос упал, покупатель не должен остаться
191
219
  // перед пустой сеткой.
@@ -198,15 +226,15 @@
198
226
  // показанное за сессию — иначе следующий клик вернёт те же вина.
199
227
  seenIds = [...new Set([...seenIds, ...results.map((w) => w.id)])];
200
228
 
201
- // Тот же жест, что и при поиске: карточки уезжают вверх, дальше лоадер
229
+ // Тот же жест, что и при поиске: запрос уходит сразу, карточки уезжают
230
+ // вверх, дальше скелетон
231
+ const request = wineaterApi.getMoreWineRecommendations(lastSearchedQuery, seenIds);
232
+ request.catch(() => {});
202
233
  await animateClearAll(220);
203
234
  results = [];
204
235
  isAnimating = false;
205
236
 
206
- const { recommendations, hasMore: more } = await wineaterApi.getMoreWineRecommendations(
207
- lastSearchedQuery,
208
- seenIds
209
- );
237
+ const { recommendations, hasMore: more } = await request;
210
238
  trackWidgetEvent(WIDGET_EVENTS.SHOW_MORE_CLICKED, storeId, {
211
239
  query: lastSearchedQuery,
212
240
  results_count: recommendations.length
@@ -224,6 +252,7 @@
224
252
  }
225
253
 
226
254
  async function handleSimilarClick(wine: Wine | RecommendedWine) {
255
+ userActed = true;
227
256
  try {
228
257
  isLoading = true;
229
258
  clearError();
@@ -232,13 +261,15 @@
232
261
  wine_title: wine.title
233
262
  });
234
263
 
264
+ const request = wineaterApi.getSimilarWines(wine.id);
265
+ request.catch(() => {});
235
266
  await animateClearAll(220);
236
267
  results = [];
237
268
  isAnimating = false;
238
269
  hasMore = false;
239
270
  selectedWineId = null;
240
271
 
241
- const similarWines = await wineaterApi.getSimilarWines(wine.id);
272
+ const similarWines = await request;
242
273
  searchQuery = wine.title;
243
274
  lastSearchedQuery = '';
244
275
 
@@ -358,22 +389,29 @@
358
389
  }
359
390
  });
360
391
 
361
- onMount(async () => {
362
- // initialize store first
363
- await appStore.initializeStore();
364
- if (!isStoreInitialized) return;
392
+ onMount(() => {
393
+ // Close popups on outside click
394
+ document.addEventListener('click', handleClickOutside);
365
395
 
366
- uiConfig = appStore.getUiConfig();
367
- if (uiConfig.customCss) {
368
- generateInlineStyle(uiConfig.customCss);
369
- }
396
+ (async () => {
397
+ // Config and the first wines do not depend on each other (the token is
398
+ // known up front), so both requests start together.
399
+ const configReady = appStore.initializeStore();
400
+ loadInstantRecommendations(configReady);
370
401
 
371
- await loadInstantRecommendations();
372
- trackWidgetEvent(WIDGET_EVENTS.WIDGET_INITIALIZED, storeId);
373
- initialized = true;
402
+ await configReady;
403
+ if (!isStoreInitialized) {
404
+ configFailed = true;
405
+ return;
406
+ }
374
407
 
375
- // Add global click listener for closing popups
376
- document.addEventListener('click', handleClickOutside);
408
+ if (uiConfig.customCss) {
409
+ generateInlineStyle(uiConfig.customCss);
410
+ }
411
+
412
+ initialized = true;
413
+ trackWidgetEvent(WIDGET_EVENTS.WIDGET_INITIALIZED, storeId);
414
+ })();
377
415
 
378
416
  return () => {
379
417
  document.removeEventListener('click', handleClickOutside);
@@ -394,10 +432,6 @@
394
432
 
395
433
  <Search bind:searchQuery onSubmit={handleSearch} {initialized} />
396
434
 
397
- {#if isLoading}
398
- <Loader />
399
- {/if}
400
-
401
435
  <div class="wr-App-content">
402
436
  {#if errorMessage}
403
437
  <div class="wr-App-error">
@@ -430,11 +464,19 @@
430
464
  </Action>
431
465
  </div>
432
466
  {/if}
467
+ {:else if isLoading}
468
+ <SkeletonTiles />
433
469
  {/if}
434
470
  </div>
435
471
 
436
472
  <Footer {countryCode} />
437
473
  </div>
474
+ {:else if !configFailed}
475
+ <!-- Shell with the same footprint as the widget while the store config loads -->
476
+ <div class="wr-App wr-App--loading" aria-busy="true">
477
+ <div class="wr-App-loadingTop"></div>
478
+ <SkeletonTiles />
479
+ </div>
438
480
  {/if}
439
481
 
440
482
  <style>
@@ -474,6 +516,11 @@
474
516
  position: relative;
475
517
  }
476
518
 
519
+ .wr-App-loadingTop {
520
+ /* roughly the height of Header + Search, so tiles do not jump once they render */
521
+ height: 150px;
522
+ }
523
+
477
524
  .wr-App-picture {
478
525
  display: flex;
479
526
  justify-content: center;
@@ -37,6 +37,8 @@
37
37
  ? 'https://czvgkhagwvmknscoerfy.supabase.co/storage/v1/object/public/static-media/divino_logo.svg?x=5'
38
38
  : 'https://czvgkhagwvmknscoerfy.supabase.co/storage/v1/object/public/static-media/logo_medium_gray.svg'}
39
39
  alt="Wineater Logo"
40
+ loading="lazy"
41
+ decoding="async"
40
42
  />
41
43
  <span class="wr-Footer-text wr-Footer-text--sommelier">
42
44
  {getTranslation({ key: 'ai_sommelier', lang: appLanguage })}
@@ -138,7 +138,7 @@
138
138
  width: 24px;
139
139
  height: 24px;
140
140
  flex: 0 0 auto;
141
- background-image: url('https://czvgkhagwvmknscoerfy.supabase.co/storage/v1/object/public/static-media//search.svg?x=1');
141
+ background-image: url("data:image/svg+xml,%3Csvg%20width=%2224%22%20height=%2225%22%20viewBox=%220%200%2024%2025%22%20fill=%22none%22%20xmlns=%22http://www.w3.org/2000/svg%22%3E%3Cpath%20d=%22M11%2019.3884C15.4183%2019.3884%2019%2015.8067%2019%2011.3884C19%206.97015%2015.4183%203.38843%2011%203.38843C6.58172%203.38843%203%206.97015%203%2011.3884C3%2015.8067%206.58172%2019.3884%2011%2019.3884Z%22%20stroke=%22%238250D2%22%20stroke-width=%222%22%20stroke-linecap=%22round%22%20stroke-linejoin=%22round%22/%3E%3Cpath%20d=%22M21%2021.3884L16.7%2017.0884%22%20stroke=%22%238250D2%22%20stroke-width=%222%22%20stroke-linecap=%22round%22%20stroke-linejoin=%22round%22/%3E%3C/svg%3E");
142
142
  background-repeat: no-repeat;
143
143
  background-size: contain;
144
144
  z-index: 2;
@@ -0,0 +1,94 @@
1
+ <!-- Placeholder cards that match the WineTile layout so the grid does not jump when real data arrives -->
2
+ <div class="wr-Skeleton" aria-hidden="true">
3
+ {#each [0, 1, 2, 3] as n (n)}
4
+ <div class="wr-Skeleton-tile">
5
+ <div class="wr-Skeleton-block wr-Skeleton-image"></div>
6
+ <div class="wr-Skeleton-block wr-Skeleton-price"></div>
7
+ <div class="wr-Skeleton-block wr-Skeleton-title"></div>
8
+ <div class="wr-Skeleton-block wr-Skeleton-title wr-Skeleton-title--short"></div>
9
+ <div class="wr-Skeleton-block wr-Skeleton-button"></div>
10
+ </div>
11
+ {/each}
12
+ </div>
13
+
14
+ <style>
15
+ .wr-Skeleton {
16
+ display: grid;
17
+ grid-template-columns: repeat(4, 1fr);
18
+ }
19
+
20
+ .wr-Skeleton-tile {
21
+ display: flex;
22
+ flex-direction: column;
23
+ align-items: center;
24
+ gap: 10px;
25
+ padding: 16px 8px;
26
+ border-right: 2px solid #f6f7fd;
27
+ }
28
+
29
+ .wr-Skeleton-tile:last-child {
30
+ border-right: none;
31
+ }
32
+
33
+ .wr-Skeleton-block {
34
+ border-radius: 6px;
35
+ background: linear-gradient(90deg, #f1f2f8 25%, #f8f9fc 50%, #f1f2f8 75%);
36
+ background-size: 200% 100%;
37
+ animation: wr-shimmer 1.2s linear infinite;
38
+ }
39
+
40
+ .wr-Skeleton-image {
41
+ width: 60%;
42
+ height: 200px;
43
+ margin-bottom: 6px;
44
+ }
45
+
46
+ .wr-Skeleton-price {
47
+ width: 40px;
48
+ height: 16px;
49
+ }
50
+
51
+ .wr-Skeleton-title {
52
+ width: 80%;
53
+ height: 14px;
54
+ }
55
+
56
+ .wr-Skeleton-title--short {
57
+ width: 55%;
58
+ }
59
+
60
+ .wr-Skeleton-button {
61
+ width: 96px;
62
+ height: 36px;
63
+ margin-top: 14px;
64
+ border-radius: 10px;
65
+ }
66
+
67
+ @keyframes wr-shimmer {
68
+ from {
69
+ background-position: 200% 0;
70
+ }
71
+ to {
72
+ background-position: -200% 0;
73
+ }
74
+ }
75
+
76
+ @media (prefers-reduced-motion: reduce) {
77
+ .wr-Skeleton-block {
78
+ animation: none;
79
+ }
80
+ }
81
+
82
+ @media screen and (max-width: 768px) {
83
+ .wr-Skeleton {
84
+ display: flex;
85
+ gap: 8px;
86
+ overflow: hidden;
87
+ padding: 8px 0;
88
+ }
89
+
90
+ .wr-Skeleton-tile {
91
+ flex: 0 0 240px;
92
+ }
93
+ }
94
+ </style>
@@ -6,6 +6,9 @@
6
6
  import { appStore } from '../services/store/app-store.svelte';
7
7
  import Action from './ui/Action.svelte';
8
8
 
9
+ // Desktop grid has 4 columns: the first row is above the fold
10
+ const FIRST_ROW_SIZE = 4;
11
+
9
12
  let lang = $derived(appStore.getAppLanguage());
10
13
 
11
14
  let {
@@ -95,7 +98,14 @@
95
98
  >
96
99
  <header class="wr-WineTile-header">
97
100
  {#if wine.image}
98
- <img src={wine.image} alt={wine.title} class="wr-WineTile-image" />
101
+ <img
102
+ src={wine.image}
103
+ alt={wine.title}
104
+ class="wr-WineTile-image"
105
+ loading={index < FIRST_ROW_SIZE ? 'eager' : 'lazy'}
106
+ fetchpriority={index < FIRST_ROW_SIZE ? 'high' : 'auto'}
107
+ decoding="async"
108
+ />
99
109
  {/if}
100
110
  {#if wine.total}
101
111
  <div class="wr-WineTile-price">
@@ -166,4 +166,14 @@ class WineaterApi {
166
166
  const DEMO_TOKEN = 'HeUw6IXuhvz6tRuXdPEGEbj3SGQ3Q2YtEc1Z8jd41Kg9LtyPb4Epu0to31SYIadG';
167
167
  const token = window?.wineaterData?.CLIENT_TOKEN || DEMO_TOKEN;
168
168
  export const apiVersion = String(window?.wineaterData?.apiVersion || '1');
169
- export const wineaterApi = new WineaterApi(token, apiVersion)
169
+ export const wineaterApi = new WineaterApi(token, apiVersion)
170
+
171
+ // Namespaces cached responses per client token and API version, so two widgets
172
+ // on one origin (or a token switch) never see each other's data. Not a secret:
173
+ // only a short non-reversible hash of the token ends up in the storage key.
174
+ function hashString(input: string): string {
175
+ let h = 5381;
176
+ for (let i = 0; i < input.length; i++) h = ((h << 5) + h + input.charCodeAt(i)) | 0;
177
+ return (h >>> 0).toString(36);
178
+ }
179
+ export const cacheScope = `${hashString(token)}:${apiVersion}`;
@@ -0,0 +1,28 @@
1
+ // Tiny stale-while-revalidate cache backed by localStorage.
2
+ // Storage can throw or be unavailable (private mode, blocked cookies, embeds),
3
+ // so every access is wrapped and the widget works the same without it.
4
+
5
+ const PREFIX = 'wineater:v1:';
6
+
7
+ type Entry<T> = { savedAt: number; value: T };
8
+
9
+ export function readCache<T>(key: string, maxAgeMs: number): T | null {
10
+ try {
11
+ const raw = window.localStorage.getItem(PREFIX + key);
12
+ if (!raw) return null;
13
+ const entry = JSON.parse(raw) as Entry<T>;
14
+ if (Date.now() - entry.savedAt > maxAgeMs) return null;
15
+ return entry.value;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ export function writeCache<T>(key: string, value: T): void {
22
+ try {
23
+ const entry: Entry<T> = { savedAt: Date.now(), value };
24
+ window.localStorage.setItem(PREFIX + key, JSON.stringify(entry));
25
+ } catch {
26
+ // quota exceeded or storage disabled: caching is best-effort
27
+ }
28
+ }
@@ -1,4 +1,5 @@
1
- import { wineaterApi } from "../api/api";
1
+ import { wineaterApi, cacheScope } from "../api/api";
2
+ import { readCache, writeCache } from "../cache";
2
3
  import type { CurrencyCode, LanguageCode } from "../i18n/i18n";
3
4
  import type { StoreConfig } from "./types";
4
5
  import { deserializeStoreConfig } from "./deserialize";
@@ -12,6 +13,8 @@ export type AppState = {
12
13
  uiConfig: ProjectUiConfig;
13
14
  };
14
15
 
16
+ const STORE_CONFIG_MAX_AGE_MS = 24 * 60 * 60 * 1000;
17
+
15
18
  let state = $state<AppState>({
16
19
  storeConfig: null,
17
20
  currentLanguage: 'en',
@@ -56,21 +59,41 @@ export const appStore = {
56
59
  getCurrency(): CurrencyCode {
57
60
  return state.storeConfig?.currency ?? 'USD';
58
61
  },
62
+ applyStoreConfig(config: unknown) {
63
+ state.storeConfig = deserializeStoreConfig(config as Parameters<typeof deserializeStoreConfig>[0]);
64
+ state.currentLanguage = state.storeConfig.lang as LanguageCode;
65
+ const urlLang = this.getLanguageFromURL();
66
+ if (urlLang && state.storeConfig.available_languages.includes(urlLang)) {
67
+ this.setStoreLanguage(urlLang);
68
+ } else {
69
+ // fallback to store default language
70
+ this.setStoreLanguage(state.storeConfig.lang as LanguageCode);
71
+ }
72
+ this.setUiConfig(getProjectUiConfig(state.storeConfig.id));
73
+ state.isConfigLoaded = true;
74
+ },
75
+ // Stale-while-revalidate: a cached config is applied synchronously (first
76
+ // paint without waiting for the network) and refreshed in the background
77
+ // for the next visit. Without a cache we wait for the network as before.
59
78
  async initializeStore() {
60
- try {
79
+ const cacheKey = `${cacheScope}:store-config`;
80
+ const fetchAndCache = async () => {
61
81
  const config = await wineaterApi.getStoreConfig();
62
- state.storeConfig = deserializeStoreConfig(config);
63
- state.currentLanguage = state.storeConfig.lang as LanguageCode;
64
- const urlLang = this.getLanguageFromURL();
65
- if (urlLang && state.storeConfig.available_languages.includes(urlLang)) {
66
- this.setStoreLanguage(urlLang);
67
- } else {
68
- // fallback to store default language
69
- this.setStoreLanguage(state.storeConfig.lang as LanguageCode);
82
+ writeCache(cacheKey, config);
83
+ return config;
84
+ };
85
+ try {
86
+ const cached = readCache<unknown>(cacheKey, STORE_CONFIG_MAX_AGE_MS);
87
+ if (cached) {
88
+ try {
89
+ this.applyStoreConfig(cached);
90
+ fetchAndCache().catch(() => {});
91
+ return;
92
+ } catch (error) {
93
+ console.warn('Ignoring invalid cached store config:', error);
94
+ }
70
95
  }
71
- this.setUiConfig(getProjectUiConfig(state.storeConfig.id));
72
-
73
- state.isConfigLoaded = true;
96
+ this.applyStoreConfig(await fetchAndCache());
74
97
  } catch (error) {
75
98
  console.error('Failed to initialize store:', error);
76
99
  state.isConfigLoaded = false;
@@ -1,12 +1,40 @@
1
+ const IMPORT_RE = /@import\s+url\(\s*['"]?([^'")]+)['"]?\s*\)\s*;?/g;
2
+
3
+ function addLink(rel: string, href: string, crossOrigin = false) {
4
+ const link = document.createElement('link');
5
+ link.rel = rel;
6
+ link.href = href;
7
+ if (crossOrigin) link.crossOrigin = 'anonymous';
8
+ document.head.appendChild(link);
9
+ }
10
+
11
+ // Opens the connections to the font hosts before the config tells us which
12
+ // font the project uses, so the stylesheet and font file start downloading
13
+ // without a fresh DNS/TLS handshake.
14
+ export function preconnectFontHosts() {
15
+ if (document.querySelector('link[rel="preconnect"][href="https://fonts.googleapis.com"]')) return;
16
+ addLink('preconnect', 'https://fonts.googleapis.com');
17
+ addLink('preconnect', 'https://fonts.gstatic.com', true);
18
+ }
19
+
1
20
  export function generateInlineStyle(cssString: string | null): string {
2
21
  if (!cssString) {
3
22
  return '';
4
23
  }
5
24
  const styleId = "custom-ui-config-style";
6
25
  if (document.getElementById(styleId)) return;
26
+
27
+ // `@import` inside <style> is only discovered after that stylesheet is
28
+ // parsed, which chains the font download behind it. A <link> is fetched in
29
+ // parallel with the rest of the page.
30
+ const css = cssString.replace(IMPORT_RE, (_match, href: string) => {
31
+ addLink('stylesheet', href);
32
+ return '';
33
+ });
34
+
7
35
  const style = document.createElement('style');
8
36
  style.id = styleId;
9
- style.innerHTML = cssString;
37
+ style.innerHTML = css;
10
38
  document.head.appendChild(style);
11
39
  return styleId;
12
- }
40
+ }
package/vite.config.js CHANGED
@@ -1,7 +1,26 @@
1
1
  import devtoolsJson from 'vite-plugin-devtools-json';
2
2
  import { defineConfig } from 'vitest/config';
3
+ import { transformWithEsbuild } from 'vite';
3
4
  import { svelte } from '@sveltejs/vite-plugin-svelte';
4
5
 
6
+ // Vite skips whitespace minification for `es` library output (to keep tree-shaking
7
+ // annotations for downstream bundlers). The widget is a final artifact loaded by a
8
+ // <script> tag, so minify it ourselves: the unminified ESM bundle was ~40% larger.
9
+ const minifyEsmBundle = {
10
+ name: 'minify-esm-bundle',
11
+ apply: 'build',
12
+ // generateBundle runs after every renderChunk, including Vite's own esbuild
13
+ // step that re-prints the code with indentation for `es` library builds
14
+ async generateBundle(options, bundle) {
15
+ if (options.format !== 'es') return;
16
+ for (const chunk of Object.values(bundle)) {
17
+ if (chunk.type !== 'chunk') continue;
18
+ const result = await transformWithEsbuild(chunk.code, chunk.fileName, { minify: true, target: 'es2020' });
19
+ chunk.code = result.code;
20
+ }
21
+ }
22
+ };
23
+
5
24
  export default defineConfig({
6
25
  server: {
7
26
  port: 3001 // Customize the port if necessary
@@ -57,8 +76,8 @@ export default defineConfig({
57
76
  },
58
77
  plugins: [
59
78
  devtoolsJson(),
79
+ minifyEsmBundle,
60
80
  svelte({
61
- compilerOptions: { hydratable: true },
62
81
  emitCss: false
63
82
  })
64
83
  ]