@usequeek/theme-kit 0.1.23 → 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 +164 -21
- 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-switcher.tsx +1 -0
- 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/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 -90
- package/tests/image-loading.test.tsx +0 -31
- package/tests/language-menu.test.tsx +0 -598
- 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/menu-placement.test.ts +0 -63
- 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,310 +0,0 @@
|
|
|
1
|
-
import { renderToStaticMarkup } from 'react-dom/server';
|
|
2
|
-
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
-
import type { JSX, ReactNode } from 'react';
|
|
4
|
-
import { StorefrontContext, StorefrontProvider, useThemeStrings } from '../provider';
|
|
5
|
-
import { ThemeMount } from '../theme-mount';
|
|
6
|
-
import { DefaultCartShell } from '../components/cart/default-cart-shell';
|
|
7
|
-
import { defaultThemeStrings, t as translate, type ThemeStringsDictionary } from '../strings/theme-strings';
|
|
8
|
-
import type { CartItem } from '../types/cart';
|
|
9
|
-
import type { ThemeModule } from '../types/theme';
|
|
10
|
-
import type { StorefrontConfig, VendorProfile } from '../types/vendor';
|
|
11
|
-
|
|
12
|
-
const FR_STRINGS: ThemeStringsDictionary = {
|
|
13
|
-
cart: {
|
|
14
|
-
title: 'Votre panier',
|
|
15
|
-
empty: { message: 'Votre panier est vide.', browse: 'Voir les produits' },
|
|
16
|
-
summary: { title: 'Résumé de commande' },
|
|
17
|
-
},
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
function render(
|
|
21
|
-
children: ReactNode,
|
|
22
|
-
options?: { locale?: string | null; strings?: ThemeStringsDictionary | ThemeStringsDictionary[] | null },
|
|
23
|
-
): string {
|
|
24
|
-
return renderToStaticMarkup(
|
|
25
|
-
<StorefrontProvider
|
|
26
|
-
vendor={{ id: 'v1', slug: 'demo-store', name: 'Demo Store' } as never}
|
|
27
|
-
config={{} as never}
|
|
28
|
-
menus={[]}
|
|
29
|
-
{...(options?.locale !== undefined ? { locale: options.locale } : {})}
|
|
30
|
-
{...(options?.strings !== undefined ? { strings: options.strings } : {})}
|
|
31
|
-
>
|
|
32
|
-
{children}
|
|
33
|
-
</StorefrontProvider>,
|
|
34
|
-
);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function Title(): JSX.Element {
|
|
38
|
-
const t = useThemeStrings();
|
|
39
|
-
return <h1>{t('cart.title')}</h1>;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
describe('client surface: StorefrontProvider strings + useThemeStrings', () => {
|
|
43
|
-
it('renders the active locale through the hook', () => {
|
|
44
|
-
expect(render(<Title />, { locale: 'fr', strings: FR_STRINGS })).toContain('<h1>Votre panier</h1>');
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('falls back per key to the English default VALUE', () => {
|
|
48
|
-
// `cart.checkout.action` is absent from the FR pack: English value, never the key.
|
|
49
|
-
function Action(): JSX.Element {
|
|
50
|
-
const t = useThemeStrings();
|
|
51
|
-
return <span>{t('cart.checkout.action')}</span>;
|
|
52
|
-
}
|
|
53
|
-
const html = render(<Action />, { locale: 'fr', strings: FR_STRINGS });
|
|
54
|
-
expect(html).toContain('<span>Checkout</span>');
|
|
55
|
-
expect(html).not.toContain('cart.checkout.action');
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('is a no-op when the prop is absent (backward compat: English as today)', () => {
|
|
59
|
-
expect(render(<Title />)).toContain('<h1>Your cart</h1>');
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
it('matches explicit English defaults exactly (prop changes nothing when absent)', () => {
|
|
63
|
-
const implicit = render(<Title />);
|
|
64
|
-
const explicit = render(<Title />, { strings: defaultThemeStrings });
|
|
65
|
-
expect(implicit).toBe(explicit);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
it('throws outside a provider, like every other kit hook', () => {
|
|
69
|
-
expect(() => renderToStaticMarkup(<Title />)).toThrow(
|
|
70
|
-
'useThemeStrings must be used within StorefrontProvider',
|
|
71
|
-
);
|
|
72
|
-
});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
describe('client surface through ThemeMount', () => {
|
|
76
|
-
function Header(): JSX.Element {
|
|
77
|
-
const t = useThemeStrings();
|
|
78
|
-
return <header>{t('cart.title')}</header>;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
const stubTheme = {
|
|
82
|
-
Layout: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
|
83
|
-
Header,
|
|
84
|
-
Footer: () => <footer />,
|
|
85
|
-
blocks: {},
|
|
86
|
-
getHeader: () => Header,
|
|
87
|
-
getFooter: () => (() => <footer />),
|
|
88
|
-
getBlock: () => (() => null),
|
|
89
|
-
pages: {},
|
|
90
|
-
manifest: { name: 'stub', slug: 'stub', description: '', version: '0' },
|
|
91
|
-
} as unknown as ThemeModule;
|
|
92
|
-
|
|
93
|
-
it('reaches themes mounted by ThemeMount', () => {
|
|
94
|
-
const html = render(
|
|
95
|
-
<ThemeMount
|
|
96
|
-
theme={stubTheme}
|
|
97
|
-
headerProps={{ menu: [], announcement: null }}
|
|
98
|
-
footerProps={{}}
|
|
99
|
-
>
|
|
100
|
-
<p>page</p>
|
|
101
|
-
</ThemeMount>,
|
|
102
|
-
{ locale: 'fr', strings: FR_STRINGS },
|
|
103
|
-
);
|
|
104
|
-
expect(html).toContain('<header>Votre panier</header>');
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
describe('manifest.strings travels with the theme', () => {
|
|
109
|
-
const MANIFEST_EN: ThemeStringsDictionary = {
|
|
110
|
-
theme: {
|
|
111
|
-
hero: { title: 'Fresh from the farm' },
|
|
112
|
-
lines: { count: { one: '{count} line', other: '{count} lines' } },
|
|
113
|
-
},
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
function ManifestHero(): JSX.Element {
|
|
117
|
-
const t = useThemeStrings();
|
|
118
|
-
return (
|
|
119
|
-
<header>
|
|
120
|
-
<h1>{t('theme.hero.title')}</h1>
|
|
121
|
-
<p>{t('theme.lines.count', { count: 1 })}</p>
|
|
122
|
-
<p>{t('theme.lines.count', { count: 5 })}</p>
|
|
123
|
-
<span>{t('cart.title')}</span>
|
|
124
|
-
</header>
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function themeWithManifest(strings?: ThemeStringsDictionary): ThemeModule {
|
|
129
|
-
return {
|
|
130
|
-
Layout: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
|
131
|
-
Header: ManifestHero,
|
|
132
|
-
Footer: () => <footer />,
|
|
133
|
-
blocks: {},
|
|
134
|
-
getHeader: () => ManifestHero,
|
|
135
|
-
getFooter: () => (() => <footer />),
|
|
136
|
-
getBlock: () => (() => null),
|
|
137
|
-
pages: {},
|
|
138
|
-
manifest: strings
|
|
139
|
-
? { name: 'manifest-theme', slug: 'manifest-theme', description: '', version: '0', strings }
|
|
140
|
-
: { name: 'manifest-theme', slug: 'manifest-theme', description: '', version: '0' },
|
|
141
|
-
} as unknown as ThemeModule;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function mount(
|
|
145
|
-
theme: ThemeModule,
|
|
146
|
-
options?: { locale?: string | null; strings?: ThemeStringsDictionary | ThemeStringsDictionary[] | null },
|
|
147
|
-
): string {
|
|
148
|
-
return render(
|
|
149
|
-
<ThemeMount theme={theme} headerProps={{ menu: [], announcement: null }} footerProps={{}}>
|
|
150
|
-
<p>page</p>
|
|
151
|
-
</ThemeMount>,
|
|
152
|
-
options,
|
|
153
|
-
);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
it('(a) renders theme + kit core English with NO provider strings prop', () => {
|
|
157
|
-
const html = mount(themeWithManifest(MANIFEST_EN));
|
|
158
|
-
expect(html).toContain('<h1>Fresh from the farm</h1>');
|
|
159
|
-
expect(html).toContain('<p>1 line</p>');
|
|
160
|
-
expect(html).toContain('<p>5 lines</p>');
|
|
161
|
-
// The manifest says nothing about kit keys: kit core English still renders.
|
|
162
|
-
expect(html).toContain('<span>Your cart</span>');
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
it('(b) manifest beats kit core English', () => {
|
|
166
|
-
const html = mount(
|
|
167
|
-
themeWithManifest({ ...MANIFEST_EN, cart: { title: 'Theme cart title' } }),
|
|
168
|
-
);
|
|
169
|
-
expect(html).toContain('<span>Theme cart title</span>');
|
|
170
|
-
expect(html).not.toContain('<span>Your cart</span>');
|
|
171
|
-
});
|
|
172
|
-
|
|
173
|
-
it('(b) host override beats manifest', () => {
|
|
174
|
-
const html = mount(themeWithManifest({ ...MANIFEST_EN, cart: { title: 'Theme cart title' } }), {
|
|
175
|
-
strings: { cart: { title: 'Host cart title' } },
|
|
176
|
-
});
|
|
177
|
-
expect(html).toContain('<span>Host cart title</span>');
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it('(b) first host dictionary wins (merchant override position)', () => {
|
|
181
|
-
const html = mount(themeWithManifest({ ...MANIFEST_EN, cart: { title: 'Theme cart title' } }), {
|
|
182
|
-
strings: [{ cart: { title: 'First' } }, { cart: { title: 'Second' } }],
|
|
183
|
-
});
|
|
184
|
-
expect(html).toContain('<span>First</span>');
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
it('(c) non-English host dict falls back per key to manifest English, never empty', () => {
|
|
188
|
-
const html = mount(themeWithManifest(MANIFEST_EN), {
|
|
189
|
-
locale: 'fr',
|
|
190
|
-
strings: { cart: { title: 'Votre panier' } },
|
|
191
|
-
});
|
|
192
|
-
expect(html).toContain('<span>Votre panier</span>');
|
|
193
|
-
expect(html).toContain('<h1>Fresh from the farm</h1>');
|
|
194
|
-
expect(html).not.toContain('<h1></h1>');
|
|
195
|
-
});
|
|
196
|
-
|
|
197
|
-
it('(d) manifest without strings behaves exactly as 0.1.18', () => {
|
|
198
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
199
|
-
try {
|
|
200
|
-
const html = mount(themeWithManifest());
|
|
201
|
-
// Theme key absent everywhere: empty + one dev warning, as in 0.1.18.
|
|
202
|
-
expect(html).toContain('<h1></h1>');
|
|
203
|
-
expect(warn).toHaveBeenCalledWith(expect.stringContaining('theme.hero.title'));
|
|
204
|
-
// Kit key: English default, unchanged.
|
|
205
|
-
expect(html).toContain('<span>Your cart</span>');
|
|
206
|
-
} finally {
|
|
207
|
-
warn.mockRestore();
|
|
208
|
-
}
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it('(e) a hand-built context value without strings type-checks and renders kit English', () => {
|
|
212
|
-
function Probe(): JSX.Element {
|
|
213
|
-
const t = useThemeStrings();
|
|
214
|
-
return <h1>{t('cart.title')}</h1>;
|
|
215
|
-
}
|
|
216
|
-
// No `strings` key at all: this literal must satisfy StorefrontContextValue
|
|
217
|
-
// (it failed to compile while the field was required in 0.1.18).
|
|
218
|
-
const value = {
|
|
219
|
-
vendor: { id: 'v1', slug: 'demo-store', name: 'Demo Store', logo: null } as VendorProfile,
|
|
220
|
-
config: { theme: 'stub', brand: {} } as StorefrontConfig,
|
|
221
|
-
menus: [],
|
|
222
|
-
basePath: '/demo-store',
|
|
223
|
-
pagesChrome: {},
|
|
224
|
-
locale: null,
|
|
225
|
-
};
|
|
226
|
-
const html = renderToStaticMarkup(
|
|
227
|
-
<StorefrontContext.Provider value={value}>
|
|
228
|
-
<Probe />
|
|
229
|
-
</StorefrontContext.Provider>,
|
|
230
|
-
);
|
|
231
|
-
expect(html).toContain('<h1>Your cart</h1>');
|
|
232
|
-
});
|
|
233
|
-
});
|
|
234
|
-
|
|
235
|
-
describe('migrated DefaultCartShell (identical English output)', () => {
|
|
236
|
-
const item: CartItem = {
|
|
237
|
-
id: 'li-1',
|
|
238
|
-
product_id: 'p-1',
|
|
239
|
-
shop_id: 's-1',
|
|
240
|
-
title: 'Egusi Soup',
|
|
241
|
-
image: null,
|
|
242
|
-
unit_price: 5000,
|
|
243
|
-
quantity: 2,
|
|
244
|
-
addons: [],
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
const noop = (): void => {};
|
|
248
|
-
|
|
249
|
-
it('empty state renders the legacy literals with no strings prop', () => {
|
|
250
|
-
const html = render(
|
|
251
|
-
<DefaultCartShell
|
|
252
|
-
items={[]}
|
|
253
|
-
currency="NGN"
|
|
254
|
-
total={0}
|
|
255
|
-
empty
|
|
256
|
-
onIncrease={noop}
|
|
257
|
-
onDecrease={noop}
|
|
258
|
-
onRemove={noop}
|
|
259
|
-
/>,
|
|
260
|
-
);
|
|
261
|
-
expect(html).toContain('Your cart</h1>');
|
|
262
|
-
expect(html).toContain('Your cart is empty.');
|
|
263
|
-
expect(html).toContain('Browse products');
|
|
264
|
-
});
|
|
265
|
-
|
|
266
|
-
it('populated state interpolates through t() with identical text', () => {
|
|
267
|
-
const html = render(
|
|
268
|
-
<DefaultCartShell
|
|
269
|
-
items={[item]}
|
|
270
|
-
currency="NGN"
|
|
271
|
-
total={10000}
|
|
272
|
-
empty={false}
|
|
273
|
-
onIncrease={noop}
|
|
274
|
-
onDecrease={noop}
|
|
275
|
-
onRemove={noop}
|
|
276
|
-
/>,
|
|
277
|
-
);
|
|
278
|
-
expect(html).toContain('each</span>');
|
|
279
|
-
expect(html).toContain('aria-label="Decrease Egusi Soup"');
|
|
280
|
-
expect(html).toContain('aria-label="Increase Egusi Soup"');
|
|
281
|
-
expect(html).toContain('aria-label="Remove Egusi Soup"');
|
|
282
|
-
expect(html).toContain('Order summary');
|
|
283
|
-
expect(html).toContain('Add order note');
|
|
284
|
-
// The note textarea (with its placeholder) renders only after the toggle
|
|
285
|
-
// opens it, so its key is asserted directly: same dictionary, same output.
|
|
286
|
-
expect(translate([defaultThemeStrings], 'cart.note.toggle')).toBe('Add order note');
|
|
287
|
-
expect(translate([defaultThemeStrings], 'cart.note.placeholder')).toBe('Order instructions (optional)');
|
|
288
|
-
expect(html).toContain('Subtotal:');
|
|
289
|
-
expect(html).toContain('terms and refund policy');
|
|
290
|
-
expect(html).toContain('Continue shopping');
|
|
291
|
-
});
|
|
292
|
-
|
|
293
|
-
it('renders French when the provider carries the active locale', () => {
|
|
294
|
-
const html = render(
|
|
295
|
-
<DefaultCartShell
|
|
296
|
-
items={[]}
|
|
297
|
-
currency="NGN"
|
|
298
|
-
total={0}
|
|
299
|
-
empty
|
|
300
|
-
onIncrease={noop}
|
|
301
|
-
onDecrease={noop}
|
|
302
|
-
onRemove={noop}
|
|
303
|
-
/>,
|
|
304
|
-
{ locale: 'fr', strings: FR_STRINGS },
|
|
305
|
-
);
|
|
306
|
-
expect(html).toContain('Votre panier</h1>');
|
|
307
|
-
expect(html).toContain('Votre panier est vide.');
|
|
308
|
-
expect(html).toContain('Voir les produits');
|
|
309
|
-
});
|
|
310
|
-
});
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
import { renderToStaticMarkup } from 'react-dom/server';
|
|
2
|
-
import type { JSX, ReactNode } from 'react';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import enDefault from '../locales/en.default.json';
|
|
5
|
-
import { StorefrontProvider, useThemeStrings } from '../provider';
|
|
6
|
-
import { defaultThemeStrings, t, type ThemeStringsDictionary } from '../strings/theme-strings';
|
|
7
|
-
import {
|
|
8
|
-
assertNoEnglishLeak,
|
|
9
|
-
findEnglishLeaks,
|
|
10
|
-
isPseudoLocalized,
|
|
11
|
-
pseudoLocalize,
|
|
12
|
-
pseudoLocalizeText,
|
|
13
|
-
} from '../strings/pseudo-locale';
|
|
14
|
-
|
|
15
|
-
const EN = enDefault as unknown as ThemeStringsDictionary;
|
|
16
|
-
|
|
17
|
-
function render(children: ReactNode, strings: ThemeStringsDictionary | null): string {
|
|
18
|
-
return renderToStaticMarkup(
|
|
19
|
-
<StorefrontProvider
|
|
20
|
-
vendor={{ id: 'v1', slug: 'demo-store', name: 'Demo Store' } as never}
|
|
21
|
-
config={{} as never}
|
|
22
|
-
menus={[]}
|
|
23
|
-
{...(strings ? { strings } : {})}
|
|
24
|
-
>
|
|
25
|
-
{children}
|
|
26
|
-
</StorefrontProvider>,
|
|
27
|
-
);
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function Title(): JSX.Element {
|
|
31
|
-
const t = useThemeStrings();
|
|
32
|
-
return <h1>{t('cart.title')}</h1>;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
describe('pseudoLocalize (pure dictionary transform)', () => {
|
|
36
|
-
it('accent-maps, expands ~30% and wraps in markers', () => {
|
|
37
|
-
expect(pseudoLocalizeText('Your cart')).toBe('[!! Ÿòùŕ çàŕţ~~~ !!]');
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it('preserves {placeholders} byte-for-byte and still interpolates', () => {
|
|
41
|
-
const pseudo = pseudoLocalizeText('{count} items');
|
|
42
|
-
expect(pseudo).toBe('[!! {count} ìţèṁš~~~~ !!]');
|
|
43
|
-
expect(pseudo).toContain('{count}');
|
|
44
|
-
expect(t([{ v: pseudo }], 'v', { count: 3 }, 'en')).toBe('[!! 3 ìţèṁš~~~~ !!]');
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
it('preserves `{{` / `}}` escapes (structure survives, literal is pseudo)', () => {
|
|
48
|
-
const pseudo = pseudoLocalizeText('a {{b}} c');
|
|
49
|
-
expect(pseudo).toContain('{{');
|
|
50
|
-
expect(pseudo).toContain('}}');
|
|
51
|
-
// Same interpolation behaviour as the source: escapes still escape.
|
|
52
|
-
expect(t([{ v: pseudo }], 'v', {}, 'en')).toBe('[!! à {ƀ} ç~~~ !!]');
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it('keeps plural-map shape (one key, every form pseudo)', () => {
|
|
56
|
-
const dict: ThemeStringsDictionary = {
|
|
57
|
-
items: { count: { one: '{count} item', other: '{count} items' } },
|
|
58
|
-
};
|
|
59
|
-
const pseudo = pseudoLocalize(dict);
|
|
60
|
-
expect(Object.keys((pseudo['items'] as Record<string, unknown>)['count'] as object)).toEqual([
|
|
61
|
-
'one',
|
|
62
|
-
'other',
|
|
63
|
-
]);
|
|
64
|
-
const forms = (pseudo['items'] as { count: { one: string; other: string } }).count;
|
|
65
|
-
expect(forms.one).toContain('{count}');
|
|
66
|
-
expect(isPseudoLocalized(forms.one)).toBe(true);
|
|
67
|
-
expect(isPseudoLocalized(forms.other)).toBe(true);
|
|
68
|
-
// t() still selects forms from the pseudo dictionary.
|
|
69
|
-
expect(t([pseudo], 'items.count', { count: 1 }, 'en')).toContain('1');
|
|
70
|
-
expect(t([pseudo], 'items.count', { count: 5 }, 'en')).toContain('5');
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
it('recurses nested objects and never touches keys (incl. flat dotted keys)', () => {
|
|
74
|
-
const dict = {
|
|
75
|
-
cart: { title: 'Your cart', empty: { message: 'Your cart is empty.' } },
|
|
76
|
-
'flat.key': 'Flat value',
|
|
77
|
-
} as unknown as ThemeStringsDictionary;
|
|
78
|
-
const pseudo = pseudoLocalize(dict);
|
|
79
|
-
expect(Object.keys(pseudo).sort()).toEqual(['cart', 'flat.key']);
|
|
80
|
-
expect(Object.keys(pseudo['cart'] as object).sort()).toEqual(['empty', 'title']);
|
|
81
|
-
expect(t([pseudo], 'flat.key')).toBe(pseudoLocalizeText('Flat value'));
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it('leaves empty values empty (an intentional empty renders no node)', () => {
|
|
85
|
-
expect(pseudoLocalizeText('')).toBe('');
|
|
86
|
-
const pseudo = pseudoLocalize({ hidden: { label: '' } });
|
|
87
|
-
expect((pseudo['hidden'] as { label: string }).label).toBe('');
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('expands very long values by ~30% (1000-char budget value)', () => {
|
|
91
|
-
const long = 'a'.repeat(1000);
|
|
92
|
-
const pseudo = pseudoLocalizeText(long);
|
|
93
|
-
const core = pseudo.slice('[!! '.length, -' !!]'.length);
|
|
94
|
-
expect(core.length).toBe(1000 + Math.round(1000 * 0.3));
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it('leaves unicode (non-Latin, emoji) intact, wrapped', () => {
|
|
98
|
-
const pseudo = pseudoLocalizeText('こんにちは世界 🛒');
|
|
99
|
-
expect(pseudo).toContain('こんにちは世界');
|
|
100
|
-
expect(pseudo).toContain('🛒');
|
|
101
|
-
expect(isPseudoLocalized(pseudo)).toBe(true);
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
it('rtl option wraps the core in U+202B … U+202C', () => {
|
|
105
|
-
const pseudo = pseudoLocalizeText('Your cart', { rtl: true });
|
|
106
|
-
expect(pseudo.includes('\u202B')).toBe(true);
|
|
107
|
-
expect(pseudo.includes('\u202C')).toBe(true);
|
|
108
|
-
expect(isPseudoLocalized(pseudo)).toBe(true);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it('custom markers are honoured by the scan', () => {
|
|
112
|
-
const pseudo = pseudoLocalizeText('Your cart', { prefix: '‹', suffix: '›' });
|
|
113
|
-
expect(pseudo.startsWith('‹')).toBe(true);
|
|
114
|
-
expect(isPseudoLocalized(pseudo)).toBe(false);
|
|
115
|
-
expect(isPseudoLocalized(pseudo, { prefix: '‹', suffix: '›' })).toBe(true);
|
|
116
|
-
expect(findEnglishLeaks(`<h1>${pseudo}</h1>`, { pseudo: { prefix: '‹', suffix: '›' } })).toEqual(
|
|
117
|
-
[],
|
|
118
|
-
);
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
describe('English leak scan (reusable theme-test primitive)', () => {
|
|
123
|
-
it('a fully pseudo render is clean', () => {
|
|
124
|
-
const html = render(<Title />, pseudoLocalize(EN));
|
|
125
|
-
expect(html).not.toContain('Your cart');
|
|
126
|
-
expect(findEnglishLeaks(html)).toEqual([]);
|
|
127
|
-
expect(() => assertNoEnglishLeak(html)).not.toThrow();
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
it('a hard-coded English string is reported (the control that proves the scan fails)', () => {
|
|
131
|
-
function Leaky(): JSX.Element {
|
|
132
|
-
const t = useThemeStrings();
|
|
133
|
-
return (
|
|
134
|
-
<main>
|
|
135
|
-
<h1>{t('cart.title')}</h1>
|
|
136
|
-
<p>Hard-coded checkout copy</p>
|
|
137
|
-
</main>
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
const html = render(<Leaky />, pseudoLocalize(EN));
|
|
141
|
-
expect(findEnglishLeaks(html)).toEqual(['Hard-coded checkout copy']);
|
|
142
|
-
expect(() => assertNoEnglishLeak(html)).toThrow(/Hard-coded checkout copy/);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('flags hard-coded user-visible attributes (aria-label, placeholder)', () => {
|
|
146
|
-
const html = `<button aria-label="Close dialog">×</button><input placeholder="Search products" />`;
|
|
147
|
-
expect(findEnglishLeaks(html)).toEqual(['Close dialog', 'Search products']);
|
|
148
|
-
expect(findEnglishLeaks(html, { includeAttributes: false })).toEqual([]);
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
it('ignores numbers, symbols and pseudo-wrapped attributes', () => {
|
|
152
|
-
const html = render(<Title />, pseudoLocalize(EN));
|
|
153
|
-
expect(findEnglishLeaks(`<span>₦5,000</span><span>2 / 5</span>${html}`)).toEqual([]);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it('allowlists brand names and codes', () => {
|
|
157
|
-
const html = `<footer><span>${pseudoLocalizeText('Powered by')}</span><span>Queek</span></footer>`;
|
|
158
|
-
expect(findEnglishLeaks(html)).toEqual(['Queek']);
|
|
159
|
-
expect(findEnglishLeaks(html, { allowlist: ['Queek'] })).toEqual([]);
|
|
160
|
-
expect(findEnglishLeaks(html, { allowlist: [/^Q/] })).toEqual([]);
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
it('pseudo of the real English default keeps every key (renamed-key drift fails here)', () => {
|
|
164
|
-
const pseudo = pseudoLocalize(EN);
|
|
165
|
-
expect(Object.keys(pseudo).sort()).toEqual(Object.keys(EN).sort());
|
|
166
|
-
// Spot values still resolve through t() — a changed English default that
|
|
167
|
-
// was not rebuilt into pseudo expectations fails loudly, never silently.
|
|
168
|
-
expect(t([pseudo], 'cart.title')).toBe(pseudoLocalizeText('Your cart'));
|
|
169
|
-
expect(t([defaultThemeStrings], 'cart.title')).toBe('Your cart');
|
|
170
|
-
});
|
|
171
|
-
});
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import { renderToStaticMarkup } from 'react-dom/server';
|
|
2
|
-
import type { JSX } from 'react';
|
|
3
|
-
import { describe, expect, it } from 'vitest';
|
|
4
|
-
import enDefault from '../locales/en.default.json';
|
|
5
|
-
import {
|
|
6
|
-
createThemeT,
|
|
7
|
-
defaultThemeStrings,
|
|
8
|
-
t,
|
|
9
|
-
type ThemeStringKey,
|
|
10
|
-
type ThemeStringsDictionary,
|
|
11
|
-
} from '../strings/theme-strings';
|
|
12
|
-
import { StorefrontProvider, useThemeStrings } from '../provider';
|
|
13
|
-
|
|
14
|
-
/** Small theme-shaped dictionary exercising nesting, plurals and flat keys. */
|
|
15
|
-
const THEME_EN = {
|
|
16
|
-
cart: {
|
|
17
|
-
title: 'Your cart',
|
|
18
|
-
empty: { message: 'Your cart is empty.' },
|
|
19
|
-
},
|
|
20
|
-
items: {
|
|
21
|
-
count: { one: '{count} item', other: '{count} items' },
|
|
22
|
-
},
|
|
23
|
-
'flat.key': 'Flat value',
|
|
24
|
-
greet: 'Hello {name}!',
|
|
25
|
-
} as const;
|
|
26
|
-
|
|
27
|
-
describe('typed keys: createThemeT (additive, opt-in)', () => {
|
|
28
|
-
it('delegates to the wrapped t (values, vars, plurals identical)', () => {
|
|
29
|
-
const dict = THEME_EN as unknown as ThemeStringsDictionary;
|
|
30
|
-
const tt = createThemeT<typeof THEME_EN>((key, vars, locale) => t([dict], key, vars, locale));
|
|
31
|
-
|
|
32
|
-
expect(tt('cart.title')).toBe('Your cart');
|
|
33
|
-
expect(tt('greet', { name: 'Ada' })).toBe('Hello Ada!');
|
|
34
|
-
expect(tt('items.count', { count: 1 })).toBe('1 item');
|
|
35
|
-
expect(tt('items.count', { count: 5 })).toBe('5 items');
|
|
36
|
-
expect(tt('flat.key')).toBe('Flat value');
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it('wraps the client useThemeStrings() hook result', () => {
|
|
40
|
-
function Title(): JSX.Element {
|
|
41
|
-
const tUntyped = useThemeStrings();
|
|
42
|
-
const tt = createThemeT<typeof THEME_EN>(tUntyped);
|
|
43
|
-
return <h1>{tt('cart.title')}</h1>;
|
|
44
|
-
}
|
|
45
|
-
const html = renderToStaticMarkup(
|
|
46
|
-
<StorefrontProvider
|
|
47
|
-
vendor={{ id: 'v1', slug: 's', name: 'S' } as never}
|
|
48
|
-
config={{} as never}
|
|
49
|
-
menus={[]}
|
|
50
|
-
strings={THEME_EN as unknown as ThemeStringsDictionary}
|
|
51
|
-
>
|
|
52
|
-
<Title />
|
|
53
|
-
</StorefrontProvider>,
|
|
54
|
-
);
|
|
55
|
-
expect(html).toContain('<h1>Your cart</h1>');
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
it('accepts every real en.default.json key (valid keys pass)', () => {
|
|
59
|
-
const tt = createThemeT<typeof enDefault>((key, vars, locale) =>
|
|
60
|
-
t([defaultThemeStrings], key, vars, locale),
|
|
61
|
-
);
|
|
62
|
-
// A sample across nesting depths — each must compile AND render English.
|
|
63
|
-
expect(tt('cart.title')).toBe('Your cart');
|
|
64
|
-
expect(tt('cart.empty.message')).toBe('Your cart is empty.');
|
|
65
|
-
expect(tt('checkout.contact.label')).toBe('Contact');
|
|
66
|
-
expect(tt('auth.login.title')).toBe('Sign in');
|
|
67
|
-
expect(tt('blog.share.copy')).toBe('Copy link');
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it('existing untyped t(key) callers keep compiling (key: string)', () => {
|
|
71
|
-
const anyKey: string = `cart.title`;
|
|
72
|
-
// A dynamic string — not a literal — still flows through the untyped fn.
|
|
73
|
-
expect(t([defaultThemeStrings], anyKey)).toBe('Your cart');
|
|
74
|
-
// A broad dictionary collapses the typed helper back to `string` (untyped).
|
|
75
|
-
const loose = createThemeT<ThemeStringsDictionary>((key, vars, locale) =>
|
|
76
|
-
t([defaultThemeStrings], key, vars, locale),
|
|
77
|
-
);
|
|
78
|
-
expect(loose(anyKey)).toBe('Your cart');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
it('drifts with the dictionary: the union is exactly the flattened keys', () => {
|
|
82
|
-
// Compile-time check (positive direction; the negative direction — typos
|
|
83
|
-
// fail — lives in tests/theme-typed-keys.fixture.ts via @ts-expect-error
|
|
84
|
-
// so `npm run typecheck` stays green).
|
|
85
|
-
const keys: Array<ThemeStringKey<typeof THEME_EN>> = [
|
|
86
|
-
'cart.title',
|
|
87
|
-
'cart.empty.message',
|
|
88
|
-
'items.count',
|
|
89
|
-
'flat.key',
|
|
90
|
-
'greet',
|
|
91
|
-
];
|
|
92
|
-
expect(keys).toHaveLength(5);
|
|
93
|
-
// Renaming/removing a key above breaks this file AND the fixture: a
|
|
94
|
-
// renamed English key is a contract change, not a silent drift.
|
|
95
|
-
expect(keys).toContain('cart.title');
|
|
96
|
-
});
|
|
97
|
-
});
|