@usequeek/theme-kit 0.1.22 → 0.1.24
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/CHANGELOG.md +37 -0
- package/LICENSE +1 -1
- package/README.md +275 -26
- package/api/collections.ts +14 -1
- package/api/orders.ts +15 -1
- package/apps/follow-card.tsx +5 -0
- package/apps/subscribe-form.tsx +2 -2
- package/components/auth/auth-flow.tsx +12 -0
- package/components/cart/cart-panel-controller.tsx +13 -0
- package/components/checkout/default-checkout-shell.tsx +5 -0
- package/components/checkout/policy-links.tsx +4 -0
- package/components/image.tsx +10 -0
- package/components/language-menu.css +349 -0
- package/components/language-menu.tsx +371 -0
- package/components/language-switcher.tsx +40 -8
- package/components/powered-by-queek.tsx +4 -3
- package/components/product-media-frame.tsx +5 -0
- package/components/product-metafields.tsx +9 -0
- package/components/variant-picker.css +4 -7
- package/edit-mode.tsx +4 -3
- package/hooks/use-address-autocomplete.ts +9 -0
- package/hooks/use-auth-flow.tsx +7 -12
- package/hooks/use-auth.ts +10 -0
- package/hooks/use-cart.ts +12 -0
- package/hooks/use-categories.ts +16 -2
- package/hooks/use-href.ts +9 -4
- package/hooks/use-posts.ts +16 -8
- package/hooks/use-product-detail.ts +25 -11
- package/hooks/use-product-questions.ts +16 -3
- package/hooks/use-products.ts +44 -22
- package/hooks/use-related-products.ts +17 -5
- package/hooks/use-resolve-coordinates.ts +9 -0
- package/hooks/use-reviews.ts +16 -2
- package/hooks/use-search.ts +10 -0
- package/hooks/use-shop-cart.ts +8 -0
- package/hooks/use-shop.ts +19 -6
- package/hooks/use-variant-selection.ts +23 -19
- package/index.ts +10 -11
- package/locales/en.default.json +4 -0
- package/navigation.tsx +2 -0
- package/package.json +25 -4
- package/page-renderer.tsx +11 -0
- package/provider.tsx +29 -3
- package/sdk/client.ts +183 -36
- package/sdk/error-handler.ts +17 -9
- package/sdk/queek-client.ts +11 -1
- package/sdk/server-store-client.ts +2 -2
- package/shared-blocks/embed.tsx +7 -3
- package/shared-blocks/manifest.ts +1 -1
- package/shared-blocks/product-qa.tsx +1 -1
- package/stores/address-modal-store.ts +1 -0
- package/stores/auth-modal-store.ts +1 -0
- package/stores/cart-panel-store.ts +1 -0
- package/stores/cart-store.ts +12 -7
- package/stores/checkout-store.ts +4 -4
- package/stores/location-store.ts +5 -0
- package/stores/orders-sheet-store.ts +1 -0
- package/stores/product-modal-store.ts +1 -0
- package/stores/search-modal-store.ts +1 -0
- package/stores/user-store.ts +1 -1
- package/strings/pseudo-locale.ts +1 -1
- package/theme-context.tsx +11 -1
- package/theme-mount.tsx +14 -0
- package/utils/format.ts +10 -1
- package/utils/menu-link.ts +8 -0
- package/utils/menu-placement.ts +91 -0
- package/utils/to-error.ts +4 -0
- package/utils/vendor-host.ts +1 -2
- package/scripts/verify-package.ts +0 -102
- package/tests/apps.test.tsx +0 -33
- package/tests/auth-email-errors.test.tsx +0 -55
- package/tests/currency-contract.test.ts +0 -85
- package/tests/default-metaobject-page.test.tsx +0 -56
- package/tests/display-format.test.ts +0 -403
- package/tests/fixtures/external-theme.tsx +0 -85
- package/tests/image-loading.test.tsx +0 -31
- package/tests/language-switcher.test.tsx +0 -350
- package/tests/list-pagination.test.tsx +0 -71
- package/tests/locale-switch.test.ts +0 -197
- package/tests/locale-transport.test.tsx +0 -321
- package/tests/manifest-parity.test.ts +0 -54
- package/tests/markdown-render.test.ts +0 -46
- package/tests/metaobjects-block.test.tsx +0 -70
- package/tests/navigation.test.tsx +0 -30
- package/tests/orders-walk.test.ts +0 -48
- package/tests/page-renderer-signature.test.tsx +0 -42
- package/tests/powered-by-queek.test.tsx +0 -65
- package/tests/powered-by-url.test.ts +0 -49
- package/tests/product-images.test.ts +0 -242
- package/tests/product-metafields.test.tsx +0 -154
- package/tests/public-surface.test.ts +0 -35
- package/tests/shop-query.test.ts +0 -64
- package/tests/shopper-chrome-strings.test.tsx +0 -271
- package/tests/storefront-client-scope.test.ts +0 -43
- package/tests/theme-hook-placement.test.tsx +0 -179
- package/tests/theme-strings-client.test.tsx +0 -310
- package/tests/theme-strings-pseudo.test.tsx +0 -171
- package/tests/theme-strings-typed.test.tsx +0 -97
- package/tests/theme-strings.test.tsx +0 -401
- package/tests/theme-typed-keys.fixture.ts +0 -76
- package/tests/use-shop-prefetch.test.tsx +0 -87
|
@@ -1,197 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import {
|
|
3
|
-
defaultStoreLocale,
|
|
4
|
-
localeHref,
|
|
5
|
-
normalizeStoreLocales,
|
|
6
|
-
resolveActiveLocale,
|
|
7
|
-
type StoreLocale,
|
|
8
|
-
type StoreLocaleInput,
|
|
9
|
-
} from '../utils/locale-switch';
|
|
10
|
-
|
|
11
|
-
function row(locale: string, extra: Partial<StoreLocaleInput> = {}): StoreLocaleInput {
|
|
12
|
-
return { locale, name: locale, native_name: locale, is_primary: false, ...extra };
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** English default, French and Simplified Chinese under prefixes. */
|
|
16
|
-
const EN_DEFAULT = normalizeStoreLocales([
|
|
17
|
-
row('en', { name: 'English', native_name: 'English', is_primary: true, is_default: true, path_prefix: '' }),
|
|
18
|
-
row('fr', { name: 'French', native_name: 'Français', is_default: false, path_prefix: 'fr' }),
|
|
19
|
-
row('zh-CN', { name: 'Chinese', native_name: '简体中文', is_default: false, path_prefix: 'zh-cn' }),
|
|
20
|
-
]);
|
|
21
|
-
|
|
22
|
-
/** French default; English (the primary) is not the default, so it lives under /en. */
|
|
23
|
-
const FR_DEFAULT = normalizeStoreLocales([
|
|
24
|
-
row('fr', { name: 'French', native_name: 'Français', is_default: true, path_prefix: '' }),
|
|
25
|
-
row('en', { name: 'English', native_name: 'English', is_primary: true, is_default: false, path_prefix: 'en' }),
|
|
26
|
-
]);
|
|
27
|
-
|
|
28
|
-
function pick(locales: StoreLocale[], code: string): StoreLocale {
|
|
29
|
-
const found = locales.find((l) => l.locale === code);
|
|
30
|
-
if (!found) throw new Error(`fixture has no ${code}`);
|
|
31
|
-
return found;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function href(
|
|
35
|
-
locales: StoreLocale[],
|
|
36
|
-
from: string,
|
|
37
|
-
to: string,
|
|
38
|
-
url: { pathname: string; search?: string; hash?: string; basePath?: string },
|
|
39
|
-
): string {
|
|
40
|
-
return localeHref({ ...url, active: pick(locales, from), target: pick(locales, to) });
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
describe('localeHref', () => {
|
|
44
|
-
it('moves the default language to a prefix and back to the root', () => {
|
|
45
|
-
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop' })).toBe('/fr/shop');
|
|
46
|
-
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr/shop', basePath: '/fr' })).toBe('/shop');
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('keeps the root path at the root of each language', () => {
|
|
50
|
-
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/' })).toBe('/fr');
|
|
51
|
-
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr', basePath: '/fr' })).toBe('/');
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it('puts English under /en when English is not the default', () => {
|
|
55
|
-
expect(href(FR_DEFAULT, 'fr', 'en', { pathname: '/shop' })).toBe('/en/shop');
|
|
56
|
-
expect(href(FR_DEFAULT, 'en', 'fr', { pathname: '/en/shop', basePath: '/en' })).toBe('/shop');
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('uses the lower-case code as the prefix (zh-CN -> /zh-cn)', () => {
|
|
60
|
-
expect(href(EN_DEFAULT, 'en', 'zh-CN', { pathname: '/products/mug' })).toBe('/zh-cn/products/mug');
|
|
61
|
-
expect(href(EN_DEFAULT, 'zh-CN', 'fr', { pathname: '/zh-cn/products/mug', basePath: '/zh-cn' })).toBe('/fr/products/mug');
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it('switches directly between two prefixed languages', () => {
|
|
65
|
-
expect(href(EN_DEFAULT, 'fr', 'zh-CN', { pathname: '/fr/shop/all', basePath: '/fr' })).toBe('/zh-cn/shop/all');
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('keeps the query string and the hash', () => {
|
|
69
|
-
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop', search: '?sort=new&page=2', hash: '#reviews' })).toBe(
|
|
70
|
-
'/fr/shop?sort=new&page=2#reviews',
|
|
71
|
-
);
|
|
72
|
-
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr', basePath: '/fr', search: '?q=a', hash: '#top' })).toBe('/?q=a#top');
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it('accepts a query string and hash without their leading marks, and ignores empty ones', () => {
|
|
76
|
-
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop', search: 'a=1', hash: 'x' })).toBe('/fr/shop?a=1#x');
|
|
77
|
-
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop', search: '?', hash: '#' })).toBe('/fr/shop');
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it('keeps a trailing slash on a nested path', () => {
|
|
81
|
-
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop/' })).toBe('/fr/shop/');
|
|
82
|
-
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr/shop/mugs/', basePath: '/fr' })).toBe('/shop/mugs/');
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('only strips whole segments: a page that starts like a language is a page', () => {
|
|
86
|
-
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr/french-press', basePath: '/fr' })).toBe('/french-press');
|
|
87
|
-
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/french-press' })).toBe('/fr/french-press');
|
|
88
|
-
// Active is the default (no prefix to strip): `/fr` as a path is a page there.
|
|
89
|
-
expect(href(EN_DEFAULT, 'en', 'zh-CN', { pathname: '/fr-news' })).toBe('/zh-cn/fr-news');
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it('strips the active prefix once: a page slug equal to the prefix survives', () => {
|
|
93
|
-
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr/fr', basePath: '/fr' })).toBe('/fr');
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('handles a path-based host (basePath carries the store slug)', () => {
|
|
97
|
-
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/acme/shop', basePath: '/acme' })).toBe('/acme/fr/shop');
|
|
98
|
-
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/acme/fr/shop', basePath: '/acme/fr' })).toBe('/acme/shop');
|
|
99
|
-
expect(href(EN_DEFAULT, 'fr', 'zh-CN', { pathname: '/acme/fr', basePath: '/acme/fr' })).toBe('/acme/zh-cn');
|
|
100
|
-
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/acme/fr', basePath: '/acme/fr' })).toBe('/acme');
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('also copes with a non-localized basePath', () => {
|
|
104
|
-
expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/acme/fr/shop', basePath: '/acme' })).toBe('/acme/shop');
|
|
105
|
-
expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/acme', basePath: '/acme' })).toBe('/acme/fr');
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
it('matches the active prefix case-insensitively', () => {
|
|
109
|
-
expect(href(EN_DEFAULT, 'zh-CN', 'en', { pathname: '/ZH-CN/shop' })).toBe('/shop');
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
it('works on an old backend without is_default (primary is the default)', () => {
|
|
113
|
-
const old = normalizeStoreLocales([
|
|
114
|
-
{ locale: 'en', name: 'English', native_name: 'English', is_primary: true, path_prefix: '' },
|
|
115
|
-
{ locale: 'fr', name: 'French', native_name: 'Français', is_primary: false, path_prefix: 'fr' },
|
|
116
|
-
]);
|
|
117
|
-
expect(old.map((l) => [l.locale, l.is_default, l.path_prefix])).toEqual([
|
|
118
|
-
['en', true, ''],
|
|
119
|
-
['fr', false, 'fr'],
|
|
120
|
-
]);
|
|
121
|
-
expect(href(old, 'en', 'fr', { pathname: '/shop' })).toBe('/fr/shop');
|
|
122
|
-
expect(href(old, 'fr', 'en', { pathname: '/fr/shop', basePath: '/fr' })).toBe('/shop');
|
|
123
|
-
});
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
describe('normalizeStoreLocales', () => {
|
|
127
|
-
it('returns [] for nothing usable', () => {
|
|
128
|
-
expect(normalizeStoreLocales(undefined)).toEqual([]);
|
|
129
|
-
expect(normalizeStoreLocales(null)).toEqual([]);
|
|
130
|
-
expect(normalizeStoreLocales([])).toEqual([]);
|
|
131
|
-
expect(normalizeStoreLocales([{} as never, { locale: '' }, null as never])).toEqual([]);
|
|
132
|
-
expect(normalizeStoreLocales('fr' as never)).toEqual([]);
|
|
133
|
-
});
|
|
134
|
-
|
|
135
|
-
it('puts the default first and keeps the rest in order', () => {
|
|
136
|
-
const out = normalizeStoreLocales([
|
|
137
|
-
row('fr', { path_prefix: 'fr' }),
|
|
138
|
-
row('en', { is_primary: true, is_default: true }),
|
|
139
|
-
row('de', { path_prefix: 'de' }),
|
|
140
|
-
]);
|
|
141
|
-
expect(out.map((l) => l.locale)).toEqual(['en', 'fr', 'de']);
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
it('keeps exactly one default, forces its prefix empty and fills empty prefixes', () => {
|
|
145
|
-
const out = normalizeStoreLocales([
|
|
146
|
-
row('en', { is_default: true, path_prefix: 'en' }),
|
|
147
|
-
row('FR', { is_default: true, path_prefix: '' }),
|
|
148
|
-
row('pt-BR', { path_prefix: '' }),
|
|
149
|
-
]);
|
|
150
|
-
expect(out.map((l) => [l.locale, l.is_default, l.path_prefix])).toEqual([
|
|
151
|
-
['en', true, ''],
|
|
152
|
-
['FR', false, 'fr'],
|
|
153
|
-
['pt-BR', false, 'pt-br'],
|
|
154
|
-
]);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it('drops repeated codes, tolerates extra fields and defaults missing ones', () => {
|
|
158
|
-
const out = normalizeStoreLocales([
|
|
159
|
-
{ locale: 'en', is_primary: true, extra: 'ignored' } as StoreLocaleInput,
|
|
160
|
-
{ locale: 'EN', is_primary: false },
|
|
161
|
-
{ locale: 'ar', path_prefix: 'ar', dir: 'rtl' },
|
|
162
|
-
]);
|
|
163
|
-
expect(out).toHaveLength(2);
|
|
164
|
-
expect(out[0]).toMatchObject({ locale: 'en', name: 'en', native_name: 'en', dir: 'ltr', hreflang: 'en', html_lang: 'en' });
|
|
165
|
-
expect(out[0]).not.toHaveProperty('extra');
|
|
166
|
-
expect(out[1]?.dir).toBe('rtl');
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
it('accepts the storefront row shape without mapping', () => {
|
|
170
|
-
const storefrontRows: Array<{
|
|
171
|
-
locale: string; name: string; native_name: string; is_primary: boolean; is_default: boolean;
|
|
172
|
-
path_prefix: string; hreflang: string; html_lang: string; dir: string;
|
|
173
|
-
}> = [
|
|
174
|
-
{ locale: 'en', name: 'English', native_name: 'English', is_primary: true, is_default: true, path_prefix: '', hreflang: 'en', html_lang: 'en', dir: 'ltr' },
|
|
175
|
-
];
|
|
176
|
-
const accepted: readonly StoreLocaleInput[] = storefrontRows;
|
|
177
|
-
const exact: StoreLocale = normalizeStoreLocales(accepted)[0]!;
|
|
178
|
-
// A storefront-typed value is assignable to StoreLocale as well.
|
|
179
|
-
const back: typeof storefrontRows[number] = exact;
|
|
180
|
-
expect(back.locale).toBe('en');
|
|
181
|
-
});
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
describe('active and default resolution', () => {
|
|
185
|
-
it('resolves the request locale case-insensitively, unknown/null to the default', () => {
|
|
186
|
-
expect(resolveActiveLocale(EN_DEFAULT, 'zh-cn').locale).toBe('zh-CN');
|
|
187
|
-
expect(resolveActiveLocale(EN_DEFAULT, 'fr').locale).toBe('fr');
|
|
188
|
-
expect(resolveActiveLocale(EN_DEFAULT, null).locale).toBe('en');
|
|
189
|
-
expect(resolveActiveLocale(EN_DEFAULT, 'xx').locale).toBe('en');
|
|
190
|
-
expect(resolveActiveLocale(FR_DEFAULT, undefined).locale).toBe('fr');
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
it('falls back to English for an empty list', () => {
|
|
194
|
-
expect(defaultStoreLocale([]).locale).toBe('en');
|
|
195
|
-
expect(resolveActiveLocale([], 'fr').path_prefix).toBe('');
|
|
196
|
-
});
|
|
197
|
-
});
|
|
@@ -1,321 +0,0 @@
|
|
|
1
|
-
import { renderToStaticMarkup } from 'react-dom/server';
|
|
2
|
-
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
-
import type { JSX, ReactNode } from 'react';
|
|
4
|
-
import {
|
|
5
|
-
LOCALE_CODE_HEADER,
|
|
6
|
-
localeCacheKeySegment,
|
|
7
|
-
parseLocaleCode,
|
|
8
|
-
withLocalePath,
|
|
9
|
-
withLocaleQuery,
|
|
10
|
-
withLocaleTag,
|
|
11
|
-
withLocaleTags,
|
|
12
|
-
} from '../utils/locale';
|
|
13
|
-
import { StorefrontProvider, useStorefrontLocale } from '../provider';
|
|
14
|
-
import { ShopPrefetchProvider, useShop } from '../hooks/use-shop';
|
|
15
|
-
import { normalizeShopQuery, type ShopPrefetch } from '../utils/shop-query';
|
|
16
|
-
import type { Product } from '../types/product';
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Theme-kit locale transport: the API answers `?locale=<code>`
|
|
20
|
-
* with translated text, the proxy sets `x-queek-locale` for published
|
|
21
|
-
* non-primary locales only, and every kit transport forwards it additively —
|
|
22
|
-
* no locale anywhere means byte-identical behaviour to before.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
const requestHeaders = vi.hoisted(() => ({ headers: new Headers(), throwOutsideScope: false }));
|
|
26
|
-
|
|
27
|
-
vi.mock('next/headers', () => ({
|
|
28
|
-
headers: async () => {
|
|
29
|
-
if (requestHeaders.throwOutsideScope) throw new Error('headers() outside request scope');
|
|
30
|
-
return requestHeaders.headers;
|
|
31
|
-
},
|
|
32
|
-
}));
|
|
33
|
-
|
|
34
|
-
function setRequestHeaders(values: Record<string, string>): void {
|
|
35
|
-
requestHeaders.headers = new Headers(values);
|
|
36
|
-
requestHeaders.throwOutsideScope = false;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
afterEach(() => {
|
|
40
|
-
vi.unstubAllGlobals();
|
|
41
|
-
setRequestHeaders({});
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
describe('parseLocaleCode (header / prop / override validation)', () => {
|
|
45
|
-
it('accepts well-formed codes', () => {
|
|
46
|
-
for (const code of ['en', 'zh', 'yo', 'pcm', 'zh-CN', 'pt-BR', 'zh-TW', 'sr-Latn']) {
|
|
47
|
-
expect(parseLocaleCode(code), code).toBe(code);
|
|
48
|
-
}
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('ignores anything else (never an error)', () => {
|
|
52
|
-
for (const bad of [
|
|
53
|
-
'',
|
|
54
|
-
'e',
|
|
55
|
-
'toolongcode123',
|
|
56
|
-
'zh!',
|
|
57
|
-
'en_US',
|
|
58
|
-
'en us',
|
|
59
|
-
'shop',
|
|
60
|
-
'products',
|
|
61
|
-
'-zh',
|
|
62
|
-
'zh-',
|
|
63
|
-
'123',
|
|
64
|
-
null,
|
|
65
|
-
undefined,
|
|
66
|
-
42,
|
|
67
|
-
{},
|
|
68
|
-
]) {
|
|
69
|
-
expect(parseLocaleCode(bad), JSON.stringify(bad)).toBeNull();
|
|
70
|
-
}
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('enforces the 12-char cap', () => {
|
|
74
|
-
expect(parseLocaleCode('ab-cdefghij')).toBe('ab-cdefghij');
|
|
75
|
-
expect(parseLocaleCode('ab-cdefghijk')).toBeNull();
|
|
76
|
-
});
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
describe('withLocaleQuery (backend URL building)', () => {
|
|
80
|
-
it('adds locale=<code> to a query', () => {
|
|
81
|
-
expect(withLocaleQuery({ per_page: 24, page: 1 }, 'zh-CN')).toEqual({ per_page: 24, page: 1, locale: 'zh-CN' });
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('builds a query from nothing when locale is set', () => {
|
|
85
|
-
expect(withLocaleQuery(undefined, 'zh')).toEqual({ locale: 'zh' });
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('returns the input untouched when no valid locale is present', () => {
|
|
89
|
-
const query = { per_page: 24 };
|
|
90
|
-
expect(withLocaleQuery(query, null)).toBe(query);
|
|
91
|
-
expect(withLocaleQuery(query, undefined)).toBe(query);
|
|
92
|
-
expect(withLocaleQuery(query, '!!')).toBe(query);
|
|
93
|
-
expect(withLocaleQuery(undefined, null)).toBeUndefined();
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
describe('cache tag suffixing (`<tag>:locale:<code>`)', () => {
|
|
98
|
-
it('suffixes every tag, lower-cased', () => {
|
|
99
|
-
expect(withLocaleTag('v:acme:theme', 'zh-CN')).toBe('v:acme:theme:locale:zh-cn');
|
|
100
|
-
expect(withLocaleTags(['v:acme', 'v:acme:theme', 'v:acme:menus'], 'zh')).toEqual([
|
|
101
|
-
'v:acme:locale:zh',
|
|
102
|
-
'v:acme:theme:locale:zh',
|
|
103
|
-
'v:acme:menus:locale:zh',
|
|
104
|
-
]);
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
it('returns tags untouched when no valid locale is present', () => {
|
|
108
|
-
const tags = ['v:acme', 'v:acme:theme'];
|
|
109
|
-
expect(withLocaleTags(tags, null)).toBe(tags);
|
|
110
|
-
expect(withLocaleTags(tags, 'shop')).toBe(tags);
|
|
111
|
-
expect(withLocaleTag('v:acme', null)).toBe('v:acme');
|
|
112
|
-
});
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
describe('withLocalePath (SDK-backed callers with path strings only)', () => {
|
|
116
|
-
it('appends locale, respecting an existing query string', () => {
|
|
117
|
-
expect(withLocalePath('/client/store/products/egusi', 'zh')).toBe('/client/store/products/egusi?locale=zh');
|
|
118
|
-
expect(withLocalePath('/x?limit=4', 'zh-CN')).toBe('/x?limit=4&locale=zh-CN');
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it('returns the path untouched without a valid locale', () => {
|
|
122
|
-
expect(withLocalePath('/client/store/products/egusi', null)).toBe('/client/store/products/egusi');
|
|
123
|
-
expect(withLocalePath('/x?limit=4', 'nope!')).toBe('/x?limit=4');
|
|
124
|
-
});
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
describe('localeCacheKeySegment (client cache keys)', () => {
|
|
128
|
-
it('separates locales and never collides with unset', () => {
|
|
129
|
-
expect(localeCacheKeySegment('zh')).toBe('zh');
|
|
130
|
-
expect(localeCacheKeySegment('zh-CN')).toBe('zh-cn');
|
|
131
|
-
expect(localeCacheKeySegment(null)).toBe('');
|
|
132
|
-
expect(localeCacheKeySegment('!!')).toBe('');
|
|
133
|
-
expect(localeCacheKeySegment('zh')).not.toBe(localeCacheKeySegment(null));
|
|
134
|
-
});
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
describe('server transport (sdk/server-store-client)', () => {
|
|
138
|
-
type CapturedInit = RequestInit & { next?: { revalidate: number; tags: string[] } };
|
|
139
|
-
|
|
140
|
-
async function capturedGet(path: string, query?: Record<string, string | number>, locale?: string): Promise<{ url: string; init: CapturedInit }> {
|
|
141
|
-
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ ok: true, json: async () => ({ data: {} }) }));
|
|
142
|
-
vi.stubGlobal('fetch', fetchMock);
|
|
143
|
-
const { createServerStoreClient } = await import('../sdk/server-store-client');
|
|
144
|
-
const client = createServerStoreClient('acme', 'http://origin.test');
|
|
145
|
-
await client.get(path, query, locale);
|
|
146
|
-
const [[url, init]] = fetchMock.mock.calls as unknown as Array<[string, CapturedInit]>;
|
|
147
|
-
return { url, init };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
it('appends locale=<code> and suffixes every tag when the header is set', async () => {
|
|
151
|
-
setRequestHeaders({ [LOCALE_CODE_HEADER]: 'zh-CN', host: 'acme.test' });
|
|
152
|
-
const { url, init } = await capturedGet('/info');
|
|
153
|
-
expect(url).toContain('locale=zh-CN');
|
|
154
|
-
expect(init.next?.tags).toEqual(['v:acme:locale:zh-cn', 'v:acme:profile:locale:zh-cn']);
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
it('is byte-identical to today with no header', async () => {
|
|
158
|
-
setRequestHeaders({ host: 'acme.test' });
|
|
159
|
-
const { url, init } = await capturedGet('/info');
|
|
160
|
-
expect(url).toBe('http://127.0.0.1:8000/api/v1/client/store/info');
|
|
161
|
-
expect(init.next).toEqual({ revalidate: 300, tags: ['v:acme', 'v:acme:profile'] });
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
it('ignores an invalid header value', async () => {
|
|
165
|
-
setRequestHeaders({ [LOCALE_CODE_HEADER]: 'not a locale!!', host: 'acme.test' });
|
|
166
|
-
const { url, init } = await capturedGet('/info');
|
|
167
|
-
expect(url).not.toContain('locale=');
|
|
168
|
-
expect(init.next?.tags).toEqual(['v:acme', 'v:acme:profile']);
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
it('never throws outside a request scope (treated as no locale)', async () => {
|
|
172
|
-
requestHeaders.throwOutsideScope = true;
|
|
173
|
-
const { readRequestLocale } = await import('../sdk/server-store-client');
|
|
174
|
-
await expect(readRequestLocale()).resolves.toBeNull();
|
|
175
|
-
requestHeaders.throwOutsideScope = false;
|
|
176
|
-
const { url } = await capturedGet('/info');
|
|
177
|
-
expect(url).not.toContain('locale=');
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it('an explicit override wins over the header', async () => {
|
|
181
|
-
setRequestHeaders({ [LOCALE_CODE_HEADER]: 'zh', host: 'acme.test' });
|
|
182
|
-
const { url, init } = await capturedGet('/info', undefined, 'fr');
|
|
183
|
-
expect(url).toContain('locale=fr');
|
|
184
|
-
expect(init.next?.tags).toEqual(['v:acme:locale:fr', 'v:acme:profile:locale:fr']);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it('still bypasses the cache for preview renders, locale or not', async () => {
|
|
188
|
-
setRequestHeaders({ 'x-queek-preview': '1', [LOCALE_CODE_HEADER]: 'zh', host: 'acme.test' });
|
|
189
|
-
const { url, init } = await capturedGet('/info');
|
|
190
|
-
expect(url).toContain('locale=zh');
|
|
191
|
-
expect(init.next).toBeUndefined();
|
|
192
|
-
expect(init.cache).toBe('no-store');
|
|
193
|
-
});
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
describe('fetchShopPrefetch (api/shop)', () => {
|
|
197
|
-
async function runPrefetch(vendorSlug = 'demo-store', locale?: string) {
|
|
198
|
-
const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ ok: true, json: async () => ({ data: [], has_more: false }) }));
|
|
199
|
-
vi.stubGlobal('fetch', fetchMock);
|
|
200
|
-
const { fetchShopPrefetch } = await import('../api/shop');
|
|
201
|
-
const prefetch = await fetchShopPrefetch(vendorSlug, normalizeShopQuery({}), locale);
|
|
202
|
-
const urls = (fetchMock.mock.calls as unknown as Array<[string]>).map(([url]) => String(url));
|
|
203
|
-
const productsCall = urls.find((url) => url.includes('/products')) ?? '';
|
|
204
|
-
return { prefetch, productsCall };
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
it('forwards the request locale and echoes it for the seed match', async () => {
|
|
208
|
-
setRequestHeaders({ [LOCALE_CODE_HEADER]: 'zh', host: 'x.test' });
|
|
209
|
-
const { prefetch, productsCall } = await runPrefetch();
|
|
210
|
-
expect(productsCall).toContain('locale=zh');
|
|
211
|
-
expect(prefetch?.locale).toBe('zh');
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it('fetches exactly as before with no locale (echo null)', async () => {
|
|
215
|
-
setRequestHeaders({ host: 'x.test' });
|
|
216
|
-
const { prefetch, productsCall } = await runPrefetch();
|
|
217
|
-
expect(productsCall).not.toContain('locale=');
|
|
218
|
-
expect(prefetch?.locale).toBeNull();
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
function product(id: string, title: string): Product {
|
|
223
|
-
return { id, title, slug: id } as unknown as Product;
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function render(children: ReactNode, locale?: string | null): string {
|
|
227
|
-
return renderToStaticMarkup(
|
|
228
|
-
<StorefrontProvider
|
|
229
|
-
vendor={{ id: 'v1', slug: 'demo-store', name: 'Demo Store' } as never}
|
|
230
|
-
config={{} as never}
|
|
231
|
-
menus={[]}
|
|
232
|
-
{...(locale !== undefined ? { locale } : {})}
|
|
233
|
-
>
|
|
234
|
-
{children}
|
|
235
|
-
</StorefrontProvider>,
|
|
236
|
-
);
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
function LocaleProbe(): JSX.Element {
|
|
240
|
-
const locale = useStorefrontLocale();
|
|
241
|
-
return <span data-locale={locale ?? 'none'} />;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
describe('StorefrontProvider locale prop + useStorefrontLocale', () => {
|
|
245
|
-
it('exposes the validated locale', () => {
|
|
246
|
-
expect(render(<LocaleProbe />, 'zh-CN')).toContain('data-locale="zh-CN"');
|
|
247
|
-
});
|
|
248
|
-
|
|
249
|
-
it('is null without the prop (unchanged behaviour)', () => {
|
|
250
|
-
expect(render(<LocaleProbe />)).toContain('data-locale="none"');
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
it('is null for an invalid prop (ignored, never an error)', () => {
|
|
254
|
-
expect(render(<LocaleProbe />, 'not a locale!!')).toContain('data-locale="none"');
|
|
255
|
-
});
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
const SEED_QUERY = normalizeShopQuery({ categorySlug: 'soups', keyword: 'egusi', sort: 'popular', page: 1, perPage: 24 });
|
|
259
|
-
|
|
260
|
-
function seedPrefetch(locale?: string | null): ShopPrefetch {
|
|
261
|
-
const prefetch: ShopPrefetch = {
|
|
262
|
-
query: SEED_QUERY,
|
|
263
|
-
products: [product('p1', 'Egusi Soup')],
|
|
264
|
-
pagination: { currentPage: 1, perPage: 24, hasMore: false },
|
|
265
|
-
};
|
|
266
|
-
if (locale !== undefined) prefetch.locale = locale;
|
|
267
|
-
return prefetch;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function Grid(props: Parameters<typeof useShop>[0]): JSX.Element {
|
|
271
|
-
const { products, isLoading } = useShop(props);
|
|
272
|
-
return (
|
|
273
|
-
<ul data-loading={String(isLoading)}>
|
|
274
|
-
{products.map((p) => <li key={p.id}>{p.title}</li>)}
|
|
275
|
-
</ul>
|
|
276
|
-
);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function renderGrid(prefetch: ShopPrefetch | null, locale?: string | null): string {
|
|
280
|
-
return render(
|
|
281
|
-
<ShopPrefetchProvider value={prefetch}>
|
|
282
|
-
<Grid categorySlug="soups" keyword="egusi" sort="popular" page={1} perPage={24} />
|
|
283
|
-
</ShopPrefetchProvider>,
|
|
284
|
-
locale,
|
|
285
|
-
);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
describe('useShop locale cache separation (seed match)', () => {
|
|
289
|
-
it('seeds from a prefetch fetched in the same locale', () => {
|
|
290
|
-
const html = renderGrid(seedPrefetch('zh'), 'zh');
|
|
291
|
-
expect(html).toContain('data-loading="false"');
|
|
292
|
-
expect(html).toContain('Egusi Soup');
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
it('ignores a prefetch fetched in another locale and refetches', () => {
|
|
296
|
-
const html = renderGrid(seedPrefetch('zh'), null);
|
|
297
|
-
expect(html).toContain('data-loading="true"');
|
|
298
|
-
expect(html).not.toContain('Egusi Soup');
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
it('keeps the old no-locale behaviour: absent echo means primary', () => {
|
|
302
|
-
const html = renderGrid(seedPrefetch(), null);
|
|
303
|
-
expect(html).toContain('data-loading="false"');
|
|
304
|
-
expect(html).toContain('Egusi Soup');
|
|
305
|
-
});
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
describe('no-locale regression (byte-identical guarantee)', () => {
|
|
309
|
-
it('server and client helpers pass input through untouched', () => {
|
|
310
|
-
const query = { per_page: 24, page: 1 };
|
|
311
|
-
expect(withLocaleQuery(query, null)).toBe(query);
|
|
312
|
-
const tags = ['v:acme', 'v:acme:products'];
|
|
313
|
-
expect(withLocaleTags(tags, null)).toBe(tags);
|
|
314
|
-
expect(withLocalePath('/client/store/products', null)).toBe('/client/store/products');
|
|
315
|
-
expect(parseLocaleCode(null)).toBeNull();
|
|
316
|
-
});
|
|
317
|
-
|
|
318
|
-
it('every hook still renders without a provider locale', () => {
|
|
319
|
-
expect(renderGrid(seedPrefetch(), null)).toContain('data-loading="false"');
|
|
320
|
-
});
|
|
321
|
-
});
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { FRAMEWORK_BLOCK_MANIFEST, FRAMEWORK_OWNED_TYPES } from '../shared-blocks/manifest';
|
|
3
|
-
import type { BlockType } from '../types/block';
|
|
4
|
-
import type { FrameworkBlockType } from '../types/theme';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Three places declare "this block type is framework-owned" and must never
|
|
8
|
-
* drift: `types/theme.ts` `FrameworkBlockType`, `shared-blocks/manifest.ts`
|
|
9
|
-
* `FRAMEWORK_OWNED_TYPES`, and `page-renderer.tsx`'s switch (checked here by
|
|
10
|
-
* proxy — every framework-owned type must at least have a manifest entry, or
|
|
11
|
-
* a theme's Section Library drawer would offer a block the renderer has no
|
|
12
|
-
* variant list for).
|
|
13
|
-
*/
|
|
14
|
-
describe('framework-owned block manifest parity', () => {
|
|
15
|
-
// Mirrors `FrameworkBlockType` in types/theme.ts — TypeScript enforces the
|
|
16
|
-
// literal union is exhaustive against `BlockType` here (compile error if
|
|
17
|
-
// one is added without the other), so this list doubles as the type check.
|
|
18
|
-
const frameworkBlockTypes: FrameworkBlockType[] = [
|
|
19
|
-
'divider',
|
|
20
|
-
'embed',
|
|
21
|
-
'video',
|
|
22
|
-
'table',
|
|
23
|
-
'button',
|
|
24
|
-
'image',
|
|
25
|
-
'reviews',
|
|
26
|
-
'faq',
|
|
27
|
-
'callout',
|
|
28
|
-
'quote',
|
|
29
|
-
'product_qa',
|
|
30
|
-
'metaobjects',
|
|
31
|
-
];
|
|
32
|
-
|
|
33
|
-
it('FrameworkBlockType and FRAMEWORK_OWNED_TYPES name the same set', () => {
|
|
34
|
-
expect(new Set(frameworkBlockTypes)).toEqual(FRAMEWORK_OWNED_TYPES);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it('every framework-owned type (except content, handled specially) has a manifest entry', () => {
|
|
38
|
-
for (const type of FRAMEWORK_OWNED_TYPES) {
|
|
39
|
-
expect(FRAMEWORK_BLOCK_MANIFEST[type], `${type} missing from FRAMEWORK_BLOCK_MANIFEST`).toBeDefined();
|
|
40
|
-
expect(FRAMEWORK_BLOCK_MANIFEST[type].length).toBeGreaterThan(0);
|
|
41
|
-
}
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('metaobjects is a real BlockType', () => {
|
|
45
|
-
const type: BlockType = 'metaobjects';
|
|
46
|
-
expect(FRAMEWORK_OWNED_TYPES.has(type)).toBe(true);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('metaobjects manifest declares grid (default) and list variants', () => {
|
|
50
|
-
const variants = FRAMEWORK_BLOCK_MANIFEST.metaobjects;
|
|
51
|
-
expect(variants.map((v) => v.id)).toEqual(expect.arrayContaining(['grid', 'list']));
|
|
52
|
-
expect(variants.find((v) => v.default)?.id).toBe('grid');
|
|
53
|
-
});
|
|
54
|
-
});
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import React from 'react';
|
|
2
|
-
import { renderToStaticMarkup } from 'react-dom/server';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import { renderMarkdown } from '../utils/markdown';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* A heading immediately followed by other content, with no blank line between them, must
|
|
8
|
-
* not be swallowed whole into the heading — `utils/markdown.tsx` once treated an entire
|
|
9
|
-
* blank-line-delimited block as heading text the moment it matched a heading prefix.
|
|
10
|
-
* Merchant copy often writes "### Our Offerings\n- item\n- item" with no blank line, which
|
|
11
|
-
* is common, valid markdown — the list must not disappear into the h3.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
function html(markdown: string): string {
|
|
15
|
-
return renderToStaticMarkup(React.createElement(React.Fragment, null, ...renderMarkdown(markdown)));
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
describe('renderMarkdown heading + trailing-content handling', () => {
|
|
19
|
-
it('a heading immediately followed by a list (no blank line) renders both, not one heading swallowing the list', () => {
|
|
20
|
-
const out = html(
|
|
21
|
-
'### Our Offerings\n- Latest fashion apparel for men, women, and children\n- Accessories to complement every outfit',
|
|
22
|
-
);
|
|
23
|
-
|
|
24
|
-
expect(out).toContain('<h3');
|
|
25
|
-
expect(out).toContain('Our Offerings');
|
|
26
|
-
expect(out).toContain('<ul');
|
|
27
|
-
expect(out).toContain('<li>Latest fashion apparel for men, women, and children</li>');
|
|
28
|
-
expect(out).toContain('<li>Accessories to complement every outfit</li>');
|
|
29
|
-
expect(out).not.toMatch(/<h3[^>]*>[^<]*Latest fashion/);
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('a heading immediately followed by a paragraph (no blank line) renders both, not one heading swallowing the text', () => {
|
|
33
|
-
const out = html('## Our Story\nWe hand-pick every piece.');
|
|
34
|
-
|
|
35
|
-
expect(out).toBe('<h2>Our Story</h2><p>We hand-pick every piece.</p>');
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('a heading with nothing after it still renders alone (regression guard)', () => {
|
|
39
|
-
expect(html('## Our Story')).toBe('<h2>Our Story</h2>');
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it('a heading followed by a blank-line-separated paragraph still renders as two blocks (regression guard)', () => {
|
|
43
|
-
const out = html('## Our Story\n\nWe hand-pick every piece.');
|
|
44
|
-
expect(out).toBe('<h2>Our Story</h2><p>We hand-pick every piece.</p>');
|
|
45
|
-
});
|
|
46
|
-
});
|