@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,401 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
2
|
-
import { join, resolve } from 'node:path';
|
|
3
|
-
import { renderToStaticMarkup } from 'react-dom/server';
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
-
import type { JSX } from 'react';
|
|
6
|
-
import enDefault from '../locales/en.default.json';
|
|
7
|
-
import {
|
|
8
|
-
canonicalThemeLocale,
|
|
9
|
-
createThemeStrings,
|
|
10
|
-
defaultThemeStrings,
|
|
11
|
-
formatThemeDate,
|
|
12
|
-
formatThemeMoney,
|
|
13
|
-
formatThemeNumber,
|
|
14
|
-
interpolate,
|
|
15
|
-
resetThemeStringsWarnings,
|
|
16
|
-
t,
|
|
17
|
-
themeLocaleDir,
|
|
18
|
-
THEME_STRING_KEY_MAX_LENGTH,
|
|
19
|
-
THEME_STRING_VALUE_MAX_LENGTH,
|
|
20
|
-
type BoundThemeStrings,
|
|
21
|
-
type ThemeStringsDictionary,
|
|
22
|
-
} from '../strings/theme-strings';
|
|
23
|
-
|
|
24
|
-
const ROOT = resolve(__dirname, '..');
|
|
25
|
-
|
|
26
|
-
beforeEach(() => {
|
|
27
|
-
resetThemeStringsWarnings();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
afterEach(() => {
|
|
31
|
-
vi.restoreAllMocks();
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
/** Partial French core pack — deliberately missing keys (fallback coverage). */
|
|
35
|
-
const FR_CORE: ThemeStringsDictionary = {
|
|
36
|
-
cart: {
|
|
37
|
-
title: 'Votre panier',
|
|
38
|
-
empty: { message: 'Votre panier est vide.' },
|
|
39
|
-
},
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const FR_THEME = {
|
|
43
|
-
'cart.empty.browse': 'Voir les produits',
|
|
44
|
-
} as unknown as ThemeStringsDictionary;
|
|
45
|
-
|
|
46
|
-
describe('t() fallback order', () => {
|
|
47
|
-
it('returns the first hit across override, theme, core, default', async () => {
|
|
48
|
-
const bound = await createThemeStrings({
|
|
49
|
-
locale: 'fr',
|
|
50
|
-
loaders: {
|
|
51
|
-
override: async () => ({ cart: { title: 'Override title' } }),
|
|
52
|
-
theme: async () => FR_THEME,
|
|
53
|
-
core: async () => FR_CORE,
|
|
54
|
-
},
|
|
55
|
-
});
|
|
56
|
-
// Override wins.
|
|
57
|
-
expect(bound.t('cart.title')).toBe('Override title');
|
|
58
|
-
// Theme fills what the override lacks (flat dotted key form).
|
|
59
|
-
expect(bound.t('cart.empty.browse')).toBe('Voir les produits');
|
|
60
|
-
// Core fills the rest.
|
|
61
|
-
expect(bound.t('cart.empty.message')).toBe('Votre panier est vide.');
|
|
62
|
-
// LAST fallback is the English default VALUE, never the raw key.
|
|
63
|
-
expect(bound.t('cart.summary.title')).toBe('Order summary');
|
|
64
|
-
expect(bound.dictionaries).toHaveLength(4);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
it('a missing key is "" (never the dotted key, never "translation missing")', () => {
|
|
68
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
69
|
-
expect(t([defaultThemeStrings], 'no.such.key')).toBe('');
|
|
70
|
-
expect(t([defaultThemeStrings], 'no.such.key')).toBe('');
|
|
71
|
-
// Warned once per key in development, silent in production shape.
|
|
72
|
-
expect(warn).toHaveBeenCalledTimes(1);
|
|
73
|
-
expect(warn.mock.calls[0]?.[0]).toContain('no.such.key');
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('an empty-string value is a real hit (renders "", does not fall through)', () => {
|
|
77
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
78
|
-
expect(t([{ hidden: { label: '' } }, defaultThemeStrings], 'hidden.label')).toBe('');
|
|
79
|
-
expect(warn).not.toHaveBeenCalled();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('a throwing loader drops out of the chain (missing locale file)', async () => {
|
|
83
|
-
const bound = await createThemeStrings({
|
|
84
|
-
locale: 'fr',
|
|
85
|
-
loaders: {
|
|
86
|
-
theme: async () => {
|
|
87
|
-
throw new Error("Cannot find module './fr.json'");
|
|
88
|
-
},
|
|
89
|
-
core: async () => null,
|
|
90
|
-
},
|
|
91
|
-
});
|
|
92
|
-
expect(bound.t('cart.title')).toBe('Your cart');
|
|
93
|
-
expect(bound.locale).toBe('fr');
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('passes the validated locale code to loaders (host dynamic-imports it)', async () => {
|
|
97
|
-
const seen: string[] = [];
|
|
98
|
-
await createThemeStrings({
|
|
99
|
-
locale: 'fr',
|
|
100
|
-
loaders: { theme: async (locale) => { seen.push(locale); return null; } },
|
|
101
|
-
});
|
|
102
|
-
expect(seen).toEqual(['fr']);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it('an invalid locale code loads English', async () => {
|
|
106
|
-
const seen: string[] = [];
|
|
107
|
-
const bound = await createThemeStrings({
|
|
108
|
-
locale: 'not a locale!!',
|
|
109
|
-
loaders: { theme: async (locale) => { seen.push(locale); return null; } },
|
|
110
|
-
});
|
|
111
|
-
expect(seen).toEqual(['en']);
|
|
112
|
-
expect(bound.locale).toBe('en');
|
|
113
|
-
});
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
describe('t() plurals (CLDR via Intl.PluralRules)', () => {
|
|
117
|
-
const dict: ThemeStringsDictionary = {
|
|
118
|
-
items: {
|
|
119
|
-
count: {
|
|
120
|
-
one: '{count} item',
|
|
121
|
-
other: '{count} items',
|
|
122
|
-
},
|
|
123
|
-
},
|
|
124
|
-
};
|
|
125
|
-
|
|
126
|
-
it('selects one/other in English', () => {
|
|
127
|
-
expect(t([dict], 'items.count', { count: 1 }, 'en')).toBe('1 item');
|
|
128
|
-
expect(t([dict], 'items.count', { count: 0 }, 'en')).toBe('0 items');
|
|
129
|
-
expect(t([dict], 'items.count', { count: 2 }, 'en')).toBe('2 items');
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
it('follows French rules (0 and 1 are "one")', () => {
|
|
133
|
-
expect(t([dict], 'items.count', { count: 0 }, 'fr')).toBe('0 item');
|
|
134
|
-
expect(t([dict], 'items.count', { count: 1 }, 'fr')).toBe('1 item');
|
|
135
|
-
expect(t([dict], 'items.count', { count: 2 }, 'fr')).toBe('2 items');
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it('follows Arabic rules ("two" exists)', () => {
|
|
139
|
-
const ar: ThemeStringsDictionary = {
|
|
140
|
-
items: { count: { zero: 'x', one: 'y', two: '{count} items-ar-two', few: 'f', many: 'm', other: 'o' } as never },
|
|
141
|
-
};
|
|
142
|
-
expect(t([ar], 'items.count', { count: 2 }, 'ar')).toBe('2 items-ar-two');
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('supports the flat "key.one / key.other" suffix convention', () => {
|
|
146
|
-
const flat = {
|
|
147
|
-
'basket.lines.one': '{count} line',
|
|
148
|
-
'basket.lines.other': '{count} lines',
|
|
149
|
-
} as unknown as ThemeStringsDictionary;
|
|
150
|
-
expect(t([flat], 'basket.lines', { count: 1 }, 'en')).toBe('1 line');
|
|
151
|
-
expect(t([flat], 'basket.lines', { count: 5 }, 'en')).toBe('5 lines');
|
|
152
|
-
});
|
|
153
|
-
|
|
154
|
-
it('missing/non-numeric count falls back to "other"', () => {
|
|
155
|
-
expect(t([dict], 'items.count', {}, 'en')).toBe('{count} items');
|
|
156
|
-
// Plural selection needs a finite numeric count; interpolation still applies.
|
|
157
|
-
expect(t([dict], 'items.count', { count: 'many' }, 'en')).toBe('many items');
|
|
158
|
-
});
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
describe('t() interpolation edge cases', () => {
|
|
162
|
-
it('keeps a missing var as its {placeholder}', () => {
|
|
163
|
-
expect(t([{ hi: 'Hello {name}!' }], 'hi', {}, 'en')).toBe('Hello {name}!');
|
|
164
|
-
expect(t([{ hi: 'Hello {name}!' }], 'hi', { name: null }, 'en')).toBe('Hello {name}!');
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
it('`{{` / `}}` escape to literal braces', () => {
|
|
168
|
-
expect(interpolate('{{{x}}}', { x: 1 })).toBe('{1}');
|
|
169
|
-
expect(interpolate('a {{b}} c', {})).toBe('a {b} c');
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
it('resolves nested keys by dot-path and flat dotted keys alike', () => {
|
|
173
|
-
const nested = { a: { b: { c: 'deep' } } } as ThemeStringsDictionary;
|
|
174
|
-
const flat = { 'a.b.c': 'flat' } as unknown as ThemeStringsDictionary;
|
|
175
|
-
expect(t([nested], 'a.b.c')).toBe('deep');
|
|
176
|
-
expect(t([flat], 'a.b.c')).toBe('flat');
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
it('stringifies numbers and booleans', () => {
|
|
180
|
-
expect(t([{ v: '{n} / {b}' }], 'v', { n: 42, b: false })).toBe('42 / false');
|
|
181
|
-
});
|
|
182
|
-
|
|
183
|
-
it('reads the real default dictionary values used by the cart shell', () => {
|
|
184
|
-
expect(t([defaultThemeStrings], 'cart.title')).toBe('Your cart');
|
|
185
|
-
expect(t([defaultThemeStrings], 'cart.item.each', { price: '₦5,000' })).toBe('₦5,000 each');
|
|
186
|
-
expect(t([defaultThemeStrings], 'cart.item.decrease', { title: 'Egusi' })).toBe('Decrease Egusi');
|
|
187
|
-
expect(t([defaultThemeStrings], 'cart.terms.agree')).toBe('I agree to the ');
|
|
188
|
-
});
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
describe('unknown locale = English', () => {
|
|
192
|
-
it('canonicalises garbage to en', () => {
|
|
193
|
-
expect(canonicalThemeLocale(null)).toBe('en');
|
|
194
|
-
expect(canonicalThemeLocale(undefined)).toBe('en');
|
|
195
|
-
expect(canonicalThemeLocale('not a locale!!')).toBe('en');
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
it('canonicalises well-formed-but-unknown codes to en', () => {
|
|
199
|
-
expect(canonicalThemeLocale('xx')).toBe('en');
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it('keeps known codes for plural/format selection', () => {
|
|
203
|
-
for (const code of ['en', 'fr', 'ar']) {
|
|
204
|
-
expect(canonicalThemeLocale(code)).toBe(code);
|
|
205
|
-
}
|
|
206
|
-
});
|
|
207
|
-
|
|
208
|
-
it('t() still renders English for an unknown locale', () => {
|
|
209
|
-
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
210
|
-
expect(t([defaultThemeStrings], 'cart.title', {}, 'xx')).toBe('Your cart');
|
|
211
|
-
expect(warn).not.toHaveBeenCalled();
|
|
212
|
-
});
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
describe('RTL dir (locale catalogue)', () => {
|
|
216
|
-
it('marks Arabic-script locales rtl, everything else ltr', () => {
|
|
217
|
-
expect(themeLocaleDir('ar')).toBe('rtl');
|
|
218
|
-
expect(themeLocaleDir('ar-EG')).toBe('rtl');
|
|
219
|
-
expect(themeLocaleDir('he')).toBe('rtl');
|
|
220
|
-
for (const code of ['en', 'fr', 'es', 'pt', 'sw', 'ha', 'yo', 'ig', 'zh', 'de', 'pcm', null, 'bogus!!']) {
|
|
221
|
-
expect(themeLocaleDir(code), String(code)).toBe('ltr');
|
|
222
|
-
}
|
|
223
|
-
});
|
|
224
|
-
});
|
|
225
|
-
|
|
226
|
-
describe('default English dictionary budget (64-character stored key)', () => {
|
|
227
|
-
function flatten(node: unknown, prefix: string, out: Array<[string, string]>): void {
|
|
228
|
-
if (typeof node === 'string') {
|
|
229
|
-
out.push([prefix, node]);
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
if (node && typeof node === 'object' && !Array.isArray(node)) {
|
|
233
|
-
for (const [k, v] of Object.entries(node as Record<string, unknown>)) {
|
|
234
|
-
flatten(v, prefix ? `${prefix}.${k}` : k, out);
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const pairs: Array<[string, string]> = [];
|
|
240
|
-
flatten(enDefault, '', pairs);
|
|
241
|
-
|
|
242
|
-
it('ships the ~106-string core dictionary (intentional growth, still budgeted)', () => {
|
|
243
|
-
// Dictionary budget: the ceiling stays tight (+9 headroom): any further
|
|
244
|
-
// growth must bump it here deliberately with its own justification, which
|
|
245
|
-
// guards against uncontrolled growth (64-character stored-key budget below).
|
|
246
|
-
expect(pairs.length).toBeGreaterThanOrEqual(70);
|
|
247
|
-
expect(pairs.length).toBeLessThanOrEqual(115);
|
|
248
|
-
});
|
|
249
|
-
|
|
250
|
-
it('every key fits the dotted-lowercase grammar and the 40-char budget', () => {
|
|
251
|
-
for (const [key] of pairs) {
|
|
252
|
-
// Same grammar as the backend overlay (ThemeStringTranslatable::KEY_PATTERN suffix) and theme-check
|
|
253
|
-
// `theme/locale-key-naming`: segments may start with a digit (e.g. `auth.2fa.code`).
|
|
254
|
-
expect(key, `grammar: ${key}`).toMatch(/^[a-z0-9]+(\.[a-z0-9]+)*$/);
|
|
255
|
-
expect(key.length, `budget: ${key}`).toBeLessThanOrEqual(THEME_STRING_KEY_MAX_LENGTH);
|
|
256
|
-
}
|
|
257
|
-
// `<theme-slug>.<key>` must fit the 64-character stored key: 40 + 1 + 23.
|
|
258
|
-
expect(Math.max(...pairs.map(([k]) => k.length))).toBeLessThanOrEqual(40);
|
|
259
|
-
});
|
|
260
|
-
|
|
261
|
-
it('every value fits the 1000-char budget', () => {
|
|
262
|
-
for (const [key, value] of pairs) {
|
|
263
|
-
expect(value.length, `value: ${key}`).toBeLessThanOrEqual(THEME_STRING_VALUE_MAX_LENGTH);
|
|
264
|
-
}
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
it('matches the runtime default export', () => {
|
|
268
|
-
expect(defaultThemeStrings).toEqual(enDefault);
|
|
269
|
-
});
|
|
270
|
-
});
|
|
271
|
-
|
|
272
|
-
describe('per-locale loading gate', () => {
|
|
273
|
-
function sourceFiles(dir: string, out: string[] = []): string[] {
|
|
274
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
275
|
-
if (entry.name === 'node_modules') continue;
|
|
276
|
-
const full = join(dir, entry.name);
|
|
277
|
-
if (entry.isDirectory()) sourceFiles(full, out);
|
|
278
|
-
else if (/\.tsx?$/.test(entry.name)) out.push(full);
|
|
279
|
-
}
|
|
280
|
-
return out;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/** Import specifiers only (static `from`/`import` and dynamic `import()`). */
|
|
284
|
-
function importSpecifiers(source: string): string[] {
|
|
285
|
-
const out: string[] = [];
|
|
286
|
-
for (const match of source.matchAll(/from\s+['"]([^'"]+)['"]/g)) out.push(match[1]);
|
|
287
|
-
for (const match of source.matchAll(/import\s*\(\s*['"`]([^'"`]+)['"`]/g)) out.push(match[1]);
|
|
288
|
-
return out;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
it('no client-reachable module statically imports a non-default locale file', () => {
|
|
292
|
-
const offenders: string[] = [];
|
|
293
|
-
for (const file of sourceFiles(ROOT)) {
|
|
294
|
-
const source = readFileSync(file, 'utf-8');
|
|
295
|
-
for (const spec of importSpecifiers(source)) {
|
|
296
|
-
if (spec.includes('locales/') && !spec.endsWith('locales/en.default.json')) {
|
|
297
|
-
offenders.push(`${file}: ${spec}`);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
expect(offenders).toEqual([]);
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
it('the kit never dynamic-imports the locales dir (only the host does, per locale)', () => {
|
|
305
|
-
const offenders: string[] = [];
|
|
306
|
-
for (const file of sourceFiles(ROOT)) {
|
|
307
|
-
if (file.endsWith('.test.ts') || file.endsWith('.test.tsx')) continue;
|
|
308
|
-
const source = readFileSync(file, 'utf-8');
|
|
309
|
-
if (/import\s*\(\s*[`'"][^`'"]*locales\//.test(source)) {
|
|
310
|
-
offenders.push(file);
|
|
311
|
-
}
|
|
312
|
-
}
|
|
313
|
-
expect(offenders).toEqual([]);
|
|
314
|
-
});
|
|
315
|
-
|
|
316
|
-
it('only the English default may be statically imported (exactly one locale ships)', () => {
|
|
317
|
-
const importers: string[] = [];
|
|
318
|
-
for (const file of sourceFiles(ROOT)) {
|
|
319
|
-
if (file.endsWith('.test.ts') || file.endsWith('.test.tsx')) continue;
|
|
320
|
-
const source = readFileSync(file, 'utf-8');
|
|
321
|
-
if (source.includes('locales/en.default.json')) importers.push(file);
|
|
322
|
-
}
|
|
323
|
-
expect(importers).toEqual([join(ROOT, 'strings', 'theme-strings.ts')]);
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
it('the pure/server-safe module imports neither next/* nor React', () => {
|
|
327
|
-
const source = readFileSync(join(ROOT, 'strings', 'theme-strings.ts'), 'utf-8');
|
|
328
|
-
expect(source).not.toMatch(/from ['"]next\//);
|
|
329
|
-
expect(source).not.toMatch(/from ['"]react['"]/);
|
|
330
|
-
expect(source).not.toContain("'use client'");
|
|
331
|
-
expect(existsSync(join(ROOT, 'locales', 'en.default.json'))).toBe(true);
|
|
332
|
-
});
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
describe('server surface: bound t with NO provider', () => {
|
|
336
|
-
function Greeting({ bound, name }: { bound: BoundThemeStrings; name: string }): JSX.Element {
|
|
337
|
-
return (
|
|
338
|
-
<main>
|
|
339
|
-
<h1>{bound.t('cart.title')}</h1>
|
|
340
|
-
<p>{bound.t('cart.empty.message')}</p>
|
|
341
|
-
<p>{bound.t('cart.item.each', { price: name })}</p>
|
|
342
|
-
</main>
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
async function renderFor(locale: string | null): Promise<{ bound: BoundThemeStrings; html: string }> {
|
|
347
|
-
const bound = await createThemeStrings({
|
|
348
|
-
locale,
|
|
349
|
-
loaders: {
|
|
350
|
-
theme: async (active) => (active === 'fr' ? FR_CORE : null),
|
|
351
|
-
},
|
|
352
|
-
});
|
|
353
|
-
return { bound, html: renderToStaticMarkup(<Greeting bound={bound} name="5 000 F" />) };
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
it('renders EN from defaults with no provider in the tree', async () => {
|
|
357
|
-
const { html } = await renderFor(null);
|
|
358
|
-
expect(html).toContain('<h1>Your cart</h1>');
|
|
359
|
-
expect(html).toContain('Your cart is empty.');
|
|
360
|
-
});
|
|
361
|
-
|
|
362
|
-
it('renders FR from the active locale only', async () => {
|
|
363
|
-
const { bound, html } = await renderFor('fr');
|
|
364
|
-
expect(bound.locale).toBe('fr');
|
|
365
|
-
expect(html).toContain('<h1>Votre panier</h1>');
|
|
366
|
-
expect(html).toContain('Votre panier est vide.');
|
|
367
|
-
// Key missing from the FR pack falls back to the English VALUE.
|
|
368
|
-
expect(html).toContain('5 000 F each');
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
it('renders AR with English values and an rtl dir', async () => {
|
|
372
|
-
const { bound, html } = await renderFor('ar');
|
|
373
|
-
expect(bound.locale).toBe('ar');
|
|
374
|
-
expect(html).toContain('<h1>Your cart</h1>');
|
|
375
|
-
expect(themeLocaleDir(bound.locale)).toBe('rtl');
|
|
376
|
-
});
|
|
377
|
-
});
|
|
378
|
-
|
|
379
|
-
describe('Intl probe (report only — en fr es pt ar sw ha yo ig zh de pcm)', () => {
|
|
380
|
-
const LOCALES = ['en', 'fr', 'es', 'pt', 'ar', 'sw', 'ha', 'yo', 'ig', 'zh', 'de', 'pcm'];
|
|
381
|
-
|
|
382
|
-
it('records native support vs silent fallback on this Node runtime', () => {
|
|
383
|
-
const rows = LOCALES.map((locale) => ({
|
|
384
|
-
locale,
|
|
385
|
-
plural: Intl.PluralRules.supportedLocalesOf([locale]).length > 0 ? 'native' : 'FALLBACK',
|
|
386
|
-
number: Intl.NumberFormat.supportedLocalesOf([locale]).length > 0 ? 'native' : 'FALLBACK',
|
|
387
|
-
date: Intl.DateTimeFormat.supportedLocalesOf([locale]).length > 0 ? 'native' : 'FALLBACK',
|
|
388
|
-
}));
|
|
389
|
-
// eslint-disable-next-line no-console
|
|
390
|
-
console.log(`[intl-probe] node ${process.version}\n${rows.map((r) => `${r.locale}: plural=${r.plural} number=${r.number} date=${r.date}`).join('\n')}`);
|
|
391
|
-
|
|
392
|
-
// Every locale still formats without throwing (helpers fall back to English).
|
|
393
|
-
for (const locale of LOCALES) {
|
|
394
|
-
expect(() => formatThemeNumber(1234.5, locale)).not.toThrow();
|
|
395
|
-
expect(() => formatThemeDate('2026-02-14', locale)).not.toThrow();
|
|
396
|
-
expect(() => formatThemeMoney(5000, 'NGN', locale)).not.toThrow();
|
|
397
|
-
expect(t([defaultThemeStrings], 'cart.title', {}, locale)).toBe('Your cart');
|
|
398
|
-
}
|
|
399
|
-
expect(rows).toHaveLength(12);
|
|
400
|
-
});
|
|
401
|
-
});
|
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Type-level test (tsd-style via tsc): a typo key FAILS and a valid key
|
|
3
|
-
* PASSES. This file must stay green under `npm run typecheck` — every
|
|
4
|
-
* `@ts-expect-error` below is consumed only if the marked line is a type
|
|
5
|
-
* error, so deleting the error (or breaking the helper) fails the gate.
|
|
6
|
-
*
|
|
7
|
-
* NOTE: intentionally self-contained (no `locales/*.json` import) so the
|
|
8
|
-
* per-locale bundle gate keeps counting exactly one static locale importer
|
|
9
|
-
* (`strings/theme-strings.ts`).
|
|
10
|
-
*/
|
|
11
|
-
import {
|
|
12
|
-
createThemeT,
|
|
13
|
-
t,
|
|
14
|
-
type ThemeStringKey,
|
|
15
|
-
type ThemeStringsDictionary,
|
|
16
|
-
} from '../strings/theme-strings';
|
|
17
|
-
|
|
18
|
-
const strings = {
|
|
19
|
-
cart: {
|
|
20
|
-
title: 'Your cart',
|
|
21
|
-
empty: { message: 'Your cart is empty.' },
|
|
22
|
-
},
|
|
23
|
-
items: {
|
|
24
|
-
count: { one: '{count} item', other: '{count} items' },
|
|
25
|
-
},
|
|
26
|
-
'flat.key': 'Flat value',
|
|
27
|
-
} as const;
|
|
28
|
-
|
|
29
|
-
type K = ThemeStringKey<typeof strings>;
|
|
30
|
-
|
|
31
|
-
// The union is EXACTLY the flattened keys (mutual assignability proves it:
|
|
32
|
-
// adding or dropping a key on either side breaks this file).
|
|
33
|
-
type Expected = 'cart.title' | 'cart.empty.message' | 'items.count' | 'flat.key';
|
|
34
|
-
const exact1: Expected = null as unknown as K;
|
|
35
|
-
const exact2: K = null as unknown as Expected;
|
|
36
|
-
void exact1;
|
|
37
|
-
void exact2;
|
|
38
|
-
|
|
39
|
-
// Valid keys pass …
|
|
40
|
-
const okTitle: K = 'cart.title';
|
|
41
|
-
const okNested: K = 'cart.empty.message';
|
|
42
|
-
const okPlural: K = 'items.count';
|
|
43
|
-
const okFlat: K = 'flat.key';
|
|
44
|
-
void okTitle;
|
|
45
|
-
void okNested;
|
|
46
|
-
void okPlural;
|
|
47
|
-
void okFlat;
|
|
48
|
-
|
|
49
|
-
// … and plural sub-forms are NOT keys (the map is one key) …
|
|
50
|
-
// @ts-expect-error - 'items.count.one' is a form, not a key
|
|
51
|
-
const badForm: K = 'items.count.one';
|
|
52
|
-
void badForm;
|
|
53
|
-
|
|
54
|
-
// … a typo key fails …
|
|
55
|
-
// @ts-expect-error - typo: 'cart.titl' is not a key
|
|
56
|
-
const badTypo: K = 'cart.titl';
|
|
57
|
-
void badTypo;
|
|
58
|
-
|
|
59
|
-
// … and an unknown key fails.
|
|
60
|
-
// @ts-expect-error - 'no.such.key' is not a key
|
|
61
|
-
const badUnknown: K = 'no.such.key';
|
|
62
|
-
void badUnknown;
|
|
63
|
-
|
|
64
|
-
// The typed binding enforces the same at the call site.
|
|
65
|
-
const dict = strings as unknown as ThemeStringsDictionary;
|
|
66
|
-
const tt = createThemeT<typeof strings>((key, vars, locale) => t([dict], key, vars, locale));
|
|
67
|
-
tt('cart.title');
|
|
68
|
-
tt('items.count', { count: 2 });
|
|
69
|
-
// @ts-expect-error - typo key fails through createThemeT too
|
|
70
|
-
tt('cart.titl');
|
|
71
|
-
|
|
72
|
-
// A broad dictionary stays untyped (existing dynamic-key callers compile).
|
|
73
|
-
declare const anyKey: string;
|
|
74
|
-
const loose = createThemeT<ThemeStringsDictionary>((key, vars, locale) => t([dict], key, vars, locale));
|
|
75
|
-
loose(anyKey);
|
|
76
|
-
t([dict], anyKey);
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { renderToStaticMarkup } from 'react-dom/server';
|
|
2
|
-
import { describe, expect, it } from 'vitest';
|
|
3
|
-
import type { JSX, ReactNode } from 'react';
|
|
4
|
-
import { StorefrontContext } from '../provider';
|
|
5
|
-
import { ShopPrefetchProvider, useShop } from '../hooks/use-shop';
|
|
6
|
-
import { normalizeShopQuery, type ShopPrefetch } from '../utils/shop-query';
|
|
7
|
-
import type { Product } from '../types/product';
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* The server renders `/shop` with the first page already fetched. What matters
|
|
11
|
-
* is what that server render contains: with a matching prefetch the grid is in
|
|
12
|
-
* the HTML and nothing is loading; with anything else, useShop behaves exactly
|
|
13
|
-
* as before (empty + loading, then fetches in the browser).
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
function product(id: string, title: string): Product {
|
|
17
|
-
return { id, title, slug: id } as unknown as Product;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const PREFETCH: ShopPrefetch = {
|
|
21
|
-
query: normalizeShopQuery({ categorySlug: 'soups', keyword: 'egusi', sort: 'popular', page: 1, perPage: 24 }),
|
|
22
|
-
products: [product('p1', 'Egusi Soup'), product('p2', 'Ogbono Soup')],
|
|
23
|
-
pagination: { currentPage: 1, perPage: 24, hasMore: true },
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
function Grid(props: Parameters<typeof useShop>[0]): JSX.Element {
|
|
27
|
-
const { products, isLoading, pagination } = useShop(props);
|
|
28
|
-
return (
|
|
29
|
-
<ul data-loading={String(isLoading)} data-has-more={String(pagination.hasMore)} data-page={pagination.currentPage}>
|
|
30
|
-
{products.map((p) => <li key={p.id}>{p.title}</li>)}
|
|
31
|
-
</ul>
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function render(children: ReactNode, opts: { prefetch?: ShopPrefetch | null; preview?: Product[] } = {}): string {
|
|
36
|
-
const store = {
|
|
37
|
-
vendor: { id: 'v1', slug: 'demo-store', name: 'Demo Store' },
|
|
38
|
-
config: {},
|
|
39
|
-
menus: [],
|
|
40
|
-
previewData: opts.preview ? { products: opts.preview } : undefined,
|
|
41
|
-
basePath: '',
|
|
42
|
-
pagesChrome: {},
|
|
43
|
-
};
|
|
44
|
-
return renderToStaticMarkup(
|
|
45
|
-
<StorefrontContext.Provider value={store as never}>
|
|
46
|
-
<ShopPrefetchProvider value={opts.prefetch ?? null}>{children}</ShopPrefetchProvider>
|
|
47
|
-
</StorefrontContext.Provider>,
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
describe('useShop + ShopPrefetchProvider', () => {
|
|
52
|
-
it('renders the prefetched page when the query matches exactly', () => {
|
|
53
|
-
const html = render(<Grid categorySlug="soups" keyword=" egusi " sort="popular" page={1} perPage={24} />, { prefetch: PREFETCH });
|
|
54
|
-
expect(html).toContain('data-loading="false"');
|
|
55
|
-
expect(html).toContain('data-has-more="true"');
|
|
56
|
-
expect(html).toContain('Egusi Soup');
|
|
57
|
-
expect(html).toContain('Ogbono Soup');
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it('ignores the prefetch for any other query (e.g. a product page\'s "popular" rail)', () => {
|
|
61
|
-
for (const other of [
|
|
62
|
-
{ sort: 'popular' as const, perPage: 8, page: 1 },
|
|
63
|
-
{ categorySlug: 'soups', keyword: 'egusi', sort: 'latest' as const },
|
|
64
|
-
{ categorySlug: 'soups', keyword: 'egusi', sort: 'popular' as const, page: 2 },
|
|
65
|
-
]) {
|
|
66
|
-
const html = render(<Grid {...other} />, { prefetch: PREFETCH });
|
|
67
|
-
expect(html).toContain('data-loading="true"');
|
|
68
|
-
expect(html).not.toContain('Egusi Soup');
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it('behaves exactly as before with no provider value', () => {
|
|
73
|
-
const html = render(<Grid categorySlug="soups" keyword="egusi" sort="popular" />);
|
|
74
|
-
expect(html).toContain('data-loading="true"');
|
|
75
|
-
expect(html).toContain('data-has-more="false"');
|
|
76
|
-
expect(html).toContain('data-page="1"');
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it('preview mode keeps using the demo catalogue, never the prefetch', () => {
|
|
80
|
-
const html = render(<Grid categorySlug="soups" keyword="egusi" sort="popular" />, {
|
|
81
|
-
prefetch: PREFETCH,
|
|
82
|
-
preview: [{ ...product('d1', 'Demo Egusi'), categories: [{ slug: 'soups' }], excerpt: '', pricing: { sale_amount: 1 }, review_summary: { review_count: 0 } } as unknown as Product],
|
|
83
|
-
});
|
|
84
|
-
expect(html).toContain('Demo Egusi');
|
|
85
|
-
expect(html).not.toContain('Ogbono Soup');
|
|
86
|
-
});
|
|
87
|
-
});
|