@usequeek/theme-kit 0.1.16 → 0.1.18

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.
@@ -0,0 +1,182 @@
1
+ import { renderToStaticMarkup } from 'react-dom/server';
2
+ import { describe, expect, it } from 'vitest';
3
+ import type { JSX, ReactNode } from 'react';
4
+ import { 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
+
11
+ const FR_STRINGS: ThemeStringsDictionary = {
12
+ cart: {
13
+ title: 'Votre panier',
14
+ empty: { message: 'Votre panier est vide.', browse: 'Voir les produits' },
15
+ summary: { title: 'Résumé de commande' },
16
+ },
17
+ };
18
+
19
+ function render(
20
+ children: ReactNode,
21
+ options?: { locale?: string | null; strings?: ThemeStringsDictionary | ThemeStringsDictionary[] | null },
22
+ ): string {
23
+ return renderToStaticMarkup(
24
+ <StorefrontProvider
25
+ vendor={{ id: 'v1', slug: 'kili-foods', name: 'Kili Foods' } as never}
26
+ config={{} as never}
27
+ menus={[]}
28
+ {...(options?.locale !== undefined ? { locale: options.locale } : {})}
29
+ {...(options?.strings !== undefined ? { strings: options.strings } : {})}
30
+ >
31
+ {children}
32
+ </StorefrontProvider>,
33
+ );
34
+ }
35
+
36
+ function Title(): JSX.Element {
37
+ const t = useThemeStrings();
38
+ return <h1>{t('cart.title')}</h1>;
39
+ }
40
+
41
+ describe('client surface: StorefrontProvider strings + useThemeStrings (decision 4b)', () => {
42
+ it('renders the active locale through the hook', () => {
43
+ expect(render(<Title />, { locale: 'fr', strings: FR_STRINGS })).toContain('<h1>Votre panier</h1>');
44
+ });
45
+
46
+ it('falls back per key to the English default VALUE', () => {
47
+ // `cart.checkout.action` is absent from the FR pack: English value, never the key.
48
+ function Action(): JSX.Element {
49
+ const t = useThemeStrings();
50
+ return <span>{t('cart.checkout.action')}</span>;
51
+ }
52
+ const html = render(<Action />, { locale: 'fr', strings: FR_STRINGS });
53
+ expect(html).toContain('<span>Checkout</span>');
54
+ expect(html).not.toContain('cart.checkout.action');
55
+ });
56
+
57
+ it('is a no-op when the prop is absent (backward compat: English as today)', () => {
58
+ expect(render(<Title />)).toContain('<h1>Your cart</h1>');
59
+ });
60
+
61
+ it('matches explicit English defaults exactly (prop changes nothing when absent)', () => {
62
+ const implicit = render(<Title />);
63
+ const explicit = render(<Title />, { strings: defaultThemeStrings });
64
+ expect(implicit).toBe(explicit);
65
+ });
66
+
67
+ it('throws outside a provider, like every other kit hook', () => {
68
+ expect(() => renderToStaticMarkup(<Title />)).toThrow(
69
+ 'useThemeStrings must be used within StorefrontProvider',
70
+ );
71
+ });
72
+ });
73
+
74
+ describe('client surface through ThemeMount', () => {
75
+ function Header(): JSX.Element {
76
+ const t = useThemeStrings();
77
+ return <header>{t('cart.title')}</header>;
78
+ }
79
+
80
+ const stubTheme = {
81
+ Layout: ({ children }: { children: ReactNode }) => <div>{children}</div>,
82
+ Header,
83
+ Footer: () => <footer />,
84
+ blocks: {},
85
+ getHeader: () => Header,
86
+ getFooter: () => (() => <footer />),
87
+ getBlock: () => (() => null),
88
+ pages: {},
89
+ manifest: { name: 'stub', slug: 'stub', description: '', version: '0' },
90
+ } as unknown as ThemeModule;
91
+
92
+ it('reaches themes mounted by ThemeMount', () => {
93
+ const html = render(
94
+ <ThemeMount
95
+ theme={stubTheme}
96
+ headerProps={{ menu: [], announcement: null }}
97
+ footerProps={{}}
98
+ >
99
+ <p>page</p>
100
+ </ThemeMount>,
101
+ { locale: 'fr', strings: FR_STRINGS },
102
+ );
103
+ expect(html).toContain('<header>Votre panier</header>');
104
+ });
105
+ });
106
+
107
+ describe('migrated DefaultCartShell (identical English output)', () => {
108
+ const item: CartItem = {
109
+ id: 'li-1',
110
+ product_id: 'p-1',
111
+ shop_id: 's-1',
112
+ title: 'Egusi Soup',
113
+ image: null,
114
+ unit_price: 5000,
115
+ quantity: 2,
116
+ addons: [],
117
+ };
118
+
119
+ const noop = (): void => {};
120
+
121
+ it('empty state renders the legacy literals with no strings prop', () => {
122
+ const html = render(
123
+ <DefaultCartShell
124
+ items={[]}
125
+ currency="NGN"
126
+ total={0}
127
+ empty
128
+ onIncrease={noop}
129
+ onDecrease={noop}
130
+ onRemove={noop}
131
+ />,
132
+ );
133
+ expect(html).toContain('Your cart</h1>');
134
+ expect(html).toContain('Your cart is empty.');
135
+ expect(html).toContain('Browse products');
136
+ });
137
+
138
+ it('populated state interpolates through t() with identical text', () => {
139
+ const html = render(
140
+ <DefaultCartShell
141
+ items={[item]}
142
+ currency="NGN"
143
+ total={10000}
144
+ empty={false}
145
+ onIncrease={noop}
146
+ onDecrease={noop}
147
+ onRemove={noop}
148
+ />,
149
+ );
150
+ expect(html).toContain('each</span>');
151
+ expect(html).toContain('aria-label="Decrease Egusi Soup"');
152
+ expect(html).toContain('aria-label="Increase Egusi Soup"');
153
+ expect(html).toContain('aria-label="Remove Egusi Soup"');
154
+ expect(html).toContain('Order summary');
155
+ expect(html).toContain('Add order note');
156
+ // The note textarea (with its placeholder) renders only after the toggle
157
+ // opens it, so its key is asserted directly: same dictionary, same output.
158
+ expect(translate([defaultThemeStrings], 'cart.note.toggle')).toBe('Add order note');
159
+ expect(translate([defaultThemeStrings], 'cart.note.placeholder')).toBe('Order instructions (optional)');
160
+ expect(html).toContain('Subtotal:');
161
+ expect(html).toContain('terms and refund policy');
162
+ expect(html).toContain('Continue shopping');
163
+ });
164
+
165
+ it('renders French when the provider carries the active locale', () => {
166
+ const html = render(
167
+ <DefaultCartShell
168
+ items={[]}
169
+ currency="NGN"
170
+ total={0}
171
+ empty
172
+ onIncrease={noop}
173
+ onDecrease={noop}
174
+ onRemove={noop}
175
+ />,
176
+ { locale: 'fr', strings: FR_STRINGS },
177
+ );
178
+ expect(html).toContain('Votre panier</h1>');
179
+ expect(html).toContain('Votre panier est vide.');
180
+ expect(html).toContain('Voir les produits');
181
+ });
182
+ });
@@ -0,0 +1,396 @@
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 (decision 3)', () => {
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 (backend varchar(64))', () => {
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 ~75-string core skeleton', () => {
243
+ expect(pairs.length).toBeGreaterThanOrEqual(70);
244
+ expect(pairs.length).toBeLessThan(100);
245
+ });
246
+
247
+ it('every key fits the dotted-lowercase grammar and the 40-char budget', () => {
248
+ for (const [key] of pairs) {
249
+ expect(key, `grammar: ${key}`).toMatch(/^[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*$/);
250
+ expect(key.length, `budget: ${key}`).toBeLessThanOrEqual(THEME_STRING_KEY_MAX_LENGTH);
251
+ }
252
+ // `<theme-slug>.<key>` must fit varchar(64): 40 + 1 + 23.
253
+ expect(Math.max(...pairs.map(([k]) => k.length))).toBeLessThanOrEqual(40);
254
+ });
255
+
256
+ it('every value fits the 1000-char budget', () => {
257
+ for (const [key, value] of pairs) {
258
+ expect(value.length, `value: ${key}`).toBeLessThanOrEqual(THEME_STRING_VALUE_MAX_LENGTH);
259
+ }
260
+ });
261
+
262
+ it('matches the runtime default export', () => {
263
+ expect(defaultThemeStrings).toEqual(enDefault);
264
+ });
265
+ });
266
+
267
+ describe('per-locale loading gate (decision 5)', () => {
268
+ function sourceFiles(dir: string, out: string[] = []): string[] {
269
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
270
+ if (entry.name === 'node_modules') continue;
271
+ const full = join(dir, entry.name);
272
+ if (entry.isDirectory()) sourceFiles(full, out);
273
+ else if (/\.tsx?$/.test(entry.name)) out.push(full);
274
+ }
275
+ return out;
276
+ }
277
+
278
+ /** Import specifiers only (static `from`/`import` and dynamic `import()`). */
279
+ function importSpecifiers(source: string): string[] {
280
+ const out: string[] = [];
281
+ for (const match of source.matchAll(/from\s+['"]([^'"]+)['"]/g)) out.push(match[1]);
282
+ for (const match of source.matchAll(/import\s*\(\s*['"`]([^'"`]+)['"`]/g)) out.push(match[1]);
283
+ return out;
284
+ }
285
+
286
+ it('no client-reachable module statically imports a non-default locale file', () => {
287
+ const offenders: string[] = [];
288
+ for (const file of sourceFiles(ROOT)) {
289
+ const source = readFileSync(file, 'utf-8');
290
+ for (const spec of importSpecifiers(source)) {
291
+ if (spec.includes('locales/') && !spec.endsWith('locales/en.default.json')) {
292
+ offenders.push(`${file}: ${spec}`);
293
+ }
294
+ }
295
+ }
296
+ expect(offenders).toEqual([]);
297
+ });
298
+
299
+ it('the kit never dynamic-imports the locales dir (only the host does, per locale)', () => {
300
+ const offenders: string[] = [];
301
+ for (const file of sourceFiles(ROOT)) {
302
+ if (file.endsWith('.test.ts') || file.endsWith('.test.tsx')) continue;
303
+ const source = readFileSync(file, 'utf-8');
304
+ if (/import\s*\(\s*[`'"][^`'"]*locales\//.test(source)) {
305
+ offenders.push(file);
306
+ }
307
+ }
308
+ expect(offenders).toEqual([]);
309
+ });
310
+
311
+ it('only the English default may be statically imported (exactly one locale ships)', () => {
312
+ const importers: string[] = [];
313
+ for (const file of sourceFiles(ROOT)) {
314
+ if (file.endsWith('.test.ts') || file.endsWith('.test.tsx')) continue;
315
+ const source = readFileSync(file, 'utf-8');
316
+ if (source.includes('locales/en.default.json')) importers.push(file);
317
+ }
318
+ expect(importers).toEqual([join(ROOT, 'strings', 'theme-strings.ts')]);
319
+ });
320
+
321
+ it('the pure/server-safe module imports neither next/* nor React', () => {
322
+ const source = readFileSync(join(ROOT, 'strings', 'theme-strings.ts'), 'utf-8');
323
+ expect(source).not.toMatch(/from ['"]next\//);
324
+ expect(source).not.toMatch(/from ['"]react['"]/);
325
+ expect(source).not.toContain("'use client'");
326
+ expect(existsSync(join(ROOT, 'locales', 'en.default.json'))).toBe(true);
327
+ });
328
+ });
329
+
330
+ describe('server surface: bound t with NO provider (decision 4a)', () => {
331
+ function Greeting({ bound, name }: { bound: BoundThemeStrings; name: string }): JSX.Element {
332
+ return (
333
+ <main>
334
+ <h1>{bound.t('cart.title')}</h1>
335
+ <p>{bound.t('cart.empty.message')}</p>
336
+ <p>{bound.t('cart.item.each', { price: name })}</p>
337
+ </main>
338
+ );
339
+ }
340
+
341
+ async function renderFor(locale: string | null): Promise<{ bound: BoundThemeStrings; html: string }> {
342
+ const bound = await createThemeStrings({
343
+ locale,
344
+ loaders: {
345
+ theme: async (active) => (active === 'fr' ? FR_CORE : null),
346
+ },
347
+ });
348
+ return { bound, html: renderToStaticMarkup(<Greeting bound={bound} name="5 000 F" />) };
349
+ }
350
+
351
+ it('renders EN from defaults with no provider in the tree', async () => {
352
+ const { html } = await renderFor(null);
353
+ expect(html).toContain('<h1>Your cart</h1>');
354
+ expect(html).toContain('Your cart is empty.');
355
+ });
356
+
357
+ it('renders FR from the active locale only', async () => {
358
+ const { bound, html } = await renderFor('fr');
359
+ expect(bound.locale).toBe('fr');
360
+ expect(html).toContain('<h1>Votre panier</h1>');
361
+ expect(html).toContain('Votre panier est vide.');
362
+ // Key missing from the FR pack falls back to the English VALUE.
363
+ expect(html).toContain('5 000 F each');
364
+ });
365
+
366
+ it('renders AR with English values and an rtl dir', async () => {
367
+ const { bound, html } = await renderFor('ar');
368
+ expect(bound.locale).toBe('ar');
369
+ expect(html).toContain('<h1>Your cart</h1>');
370
+ expect(themeLocaleDir(bound.locale)).toBe('rtl');
371
+ });
372
+ });
373
+
374
+ describe('Intl probe (report only — en fr es pt ar sw ha yo ig zh de pcm)', () => {
375
+ const LOCALES = ['en', 'fr', 'es', 'pt', 'ar', 'sw', 'ha', 'yo', 'ig', 'zh', 'de', 'pcm'];
376
+
377
+ it('records native support vs silent fallback on this Node runtime', () => {
378
+ const rows = LOCALES.map((locale) => ({
379
+ locale,
380
+ plural: Intl.PluralRules.supportedLocalesOf([locale]).length > 0 ? 'native' : 'FALLBACK',
381
+ number: Intl.NumberFormat.supportedLocalesOf([locale]).length > 0 ? 'native' : 'FALLBACK',
382
+ date: Intl.DateTimeFormat.supportedLocalesOf([locale]).length > 0 ? 'native' : 'FALLBACK',
383
+ }));
384
+ // eslint-disable-next-line no-console
385
+ console.log(`[intl-probe] node ${process.version}\n${rows.map((r) => `${r.locale}: plural=${r.plural} number=${r.number} date=${r.date}`).join('\n')}`);
386
+
387
+ // Every locale still formats without throwing (helpers fall back to English).
388
+ for (const locale of LOCALES) {
389
+ expect(() => formatThemeNumber(1234.5, locale)).not.toThrow();
390
+ expect(() => formatThemeDate('2026-02-14', locale)).not.toThrow();
391
+ expect(() => formatThemeMoney(5000, 'NGN', locale)).not.toThrow();
392
+ expect(t([defaultThemeStrings], 'cart.title', {}, locale)).toBe('Your cart');
393
+ }
394
+ expect(rows).toHaveLength(12);
395
+ });
396
+ });
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Storefront locale transport — the kit side of the translations plan.
3
+ *
4
+ * Contract (translations-core.md, decisions closed): the backend accepts
5
+ * `?locale=<code>` on every customer-facing read and answers translated text
6
+ * (unknown/unpublished locale → source text, no error). The storefront proxy
7
+ * sets `x-queek-locale` on the incoming request for published NON-primary
8
+ * locales only — the header is ABSENT on the primary locale — and suffixes
9
+ * revalidation tags as `<tag>:locale:<code>`.
10
+ *
11
+ * This module is deliberately pure (no `next/*` imports) so both sides share
12
+ * it: the server transport (`sdk/server-store-client`) and the browser hooks
13
+ * (`hooks/*` via `provider.tsx`). The ONLY `next/headers` read lives in
14
+ * `sdk/server-store-client.ts` (`readRequestLocale`) — never parse the header
15
+ * anywhere else.
16
+ *
17
+ * Backwards compatibility: every function here is a no-op returning its input
18
+ * unchanged when no valid locale is present, so no-locale behaviour is
19
+ * byte-identical to before.
20
+ */
21
+
22
+ /** Request header the proxy sets for published non-primary locales. */
23
+ export const LOCALE_CODE_HEADER = 'x-queek-locale';
24
+
25
+ /** Infix the backend sends on revalidation tags for one locale's entries. */
26
+ export const LOCALE_TAG_INFIX = ':locale:';
27
+
28
+ const LOCALE_CODE_PATTERN = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
29
+ const LOCALE_CODE_MAX_LENGTH = 12;
30
+
31
+ /**
32
+ * Validates a locale code (header value, provider prop or explicit override).
33
+ * Anything else — absent, wrong shape, too long — is ignored (null), mirroring
34
+ * the backend's unknown-locale → source-text rule: never an error.
35
+ */
36
+ export function parseLocaleCode(value: unknown): string | null {
37
+ if (typeof value !== 'string' || value.length === 0 || value.length > LOCALE_CODE_MAX_LENGTH) {
38
+ return null;
39
+ }
40
+ return LOCALE_CODE_PATTERN.test(value) ? value : null;
41
+ }
42
+
43
+ type QueryRecord = Record<string, string | number | boolean | string[] | null | undefined>;
44
+
45
+ /**
46
+ * Adds `?locale=<code>` to a backend query. No valid locale → the input is
47
+ * returned untouched (same reference), so the built URL is byte-identical.
48
+ */
49
+ export function withLocaleQuery<T extends QueryRecord>(
50
+ query: T | undefined,
51
+ locale: string | null | undefined,
52
+ ): T | (T & { locale: string }) | undefined {
53
+ const code = parseLocaleCode(locale);
54
+ if (!code) return query;
55
+ return { ...(query ?? ({} as T)), locale: code };
56
+ }
57
+
58
+ /** Cache tag for one locale's entries of a resource (`<tag>:locale:<code>`). */
59
+ export function withLocaleTag(tag: string, locale: string | null | undefined): string {
60
+ const code = parseLocaleCode(locale);
61
+ return code ? `${tag}${LOCALE_TAG_INFIX}${code.toLowerCase()}` : tag;
62
+ }
63
+
64
+ /**
65
+ * Suffixes EVERY cache tag with `:locale:<code>` so one locale's entries
66
+ * never share a cache entry with another's (or the primary's). No valid
67
+ * locale → the input array is returned untouched (same reference).
68
+ */
69
+ export function withLocaleTags(tags: readonly string[], locale: string | null | undefined): string[] {
70
+ const code = parseLocaleCode(locale);
71
+ if (!code) return tags as string[];
72
+ return tags.map((tag) => `${tag}${LOCALE_TAG_INFIX}${code.toLowerCase()}`);
73
+ }
74
+
75
+ /**
76
+ * Appends `?locale=<code>` to an already-built request path — for callers
77
+ * whose client takes a path string only (the SDK-backed `getQueekClient`,
78
+ * whose `get(path, options?)` carries headers but no query). No valid locale
79
+ * → the path is returned untouched.
80
+ */
81
+ export function withLocalePath(path: string, locale: string | null | undefined): string {
82
+ const code = parseLocaleCode(locale);
83
+ if (!code) return path;
84
+ return `${path}${path.includes('?') ? '&' : '?'}locale=${encodeURIComponent(code)}`;
85
+ }
86
+
87
+ /**
88
+ * The locale dimension of a client-side cache key (`''` when unset, so
89
+ * no-locale keys are exactly what they always were). Two locales never share
90
+ * a client cache entry; unset never collides with set.
91
+ */
92
+ export function localeCacheKeySegment(locale: string | null | undefined): string {
93
+ const code = parseLocaleCode(locale);
94
+ return code ? code.toLowerCase() : '';
95
+ }
@@ -41,6 +41,13 @@ export interface ShopPrefetch {
41
41
  query: ShopQuery;
42
42
  products: Product[];
43
43
  pagination: ShopPagination;
44
+ /**
45
+ * Request locale the page was fetched in (null = primary). `useShop` only
46
+ * seeds from a prefetch whose locale matches its own, so two locales never
47
+ * share the seeded grid. Absent (older callers) means primary, exactly as
48
+ * before.
49
+ */
50
+ locale?: string | null;
44
51
  }
45
52
 
46
53
  export function normalizeShopQuery(input?: {