@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.
- package/README.md +67 -0
- package/api/collections.ts +8 -6
- package/api/shop.ts +8 -4
- package/components/cart/default-cart-shell.tsx +23 -21
- package/hooks/use-categories.ts +5 -3
- package/hooks/use-posts.ts +5 -3
- package/hooks/use-product-detail.ts +11 -2
- package/hooks/use-product-questions.ts +5 -3
- package/hooks/use-product-sections.ts +12 -5
- package/hooks/use-products.ts +9 -5
- package/hooks/use-related-products.ts +5 -3
- package/hooks/use-reviews.ts +6 -3
- package/hooks/use-shop.ts +25 -13
- package/index.ts +3 -0
- package/locales/en.default.json +138 -0
- package/package.json +2 -1
- package/provider.tsx +88 -1
- package/sdk/server-store-client.ts +60 -6
- package/strings/theme-strings.ts +356 -0
- package/tests/locale-transport.test.tsx +321 -0
- package/tests/theme-strings-client.test.tsx +182 -0
- package/tests/theme-strings.test.tsx +396 -0
- package/utils/locale.ts +95 -0
- package/utils/shop-query.ts +7 -0
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme strings — the kit's Shopify-shaped UI-string mechanism (slice K0).
|
|
3
|
+
*
|
|
4
|
+
* PURE AND SERVER-SAFE: this module imports nothing from `next/*` and nothing
|
|
5
|
+
* from React, so server components, Node tooling and the browser all share it
|
|
6
|
+
* (precedent: `utils/locale.ts`). Keep it that way — `tests/theme-strings*`
|
|
7
|
+
* fails a build that adds either import here.
|
|
8
|
+
*
|
|
9
|
+
* Two surfaces (plan decision 4):
|
|
10
|
+
* - Server: `createThemeStrings({ locale, loaders })` returns a bound `t`
|
|
11
|
+
* that a server component receives via props/closure. NO provider in the
|
|
12
|
+
* tree. The host supplies the loaders (per-locale dynamic imports) so the
|
|
13
|
+
* kit never bundles locale files.
|
|
14
|
+
* - Client: `StorefrontProvider` takes an optional `strings` prop and
|
|
15
|
+
* `useThemeStrings()` returns the same bound `t`. Absent prop = English
|
|
16
|
+
* defaults, byte-identical to the old hardcoded literals.
|
|
17
|
+
*
|
|
18
|
+
* Fallback (plan decision 3): the caller supplies an ordered list of
|
|
19
|
+
* dictionaries (merchant override, theme locale, kit core locale) and `t`
|
|
20
|
+
* returns the FIRST hit. The LAST entry is always the English default value —
|
|
21
|
+
* never the raw dotted key, never "translation missing". A key absent
|
|
22
|
+
* everywhere returns `''` (and warns once per key in development only).
|
|
23
|
+
*
|
|
24
|
+
* Key budget: dotted lowercase `scope.thing.state`, total length <= 40 chars.
|
|
25
|
+
* The backend overlay stores `<theme-slug>.<key>` in a varchar(64), so 40 for
|
|
26
|
+
* the key leaves 23 for the slug plus the dot separator. Values <= 1000 chars.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { parseLocaleCode } from '../utils/locale';
|
|
30
|
+
import enDefault from '../locales/en.default.json';
|
|
31
|
+
|
|
32
|
+
/** Interpolation variables: `{name}` in a value. `count` drives plurals. */
|
|
33
|
+
export type ThemeStringsVars = Record<string, string | number | boolean | null | undefined>;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* CLDR plural variants for one key, using the `one/two/few/many/other`
|
|
37
|
+
* suffix convention. `other` is required (the fallback form); the rest are
|
|
38
|
+
* used only when `Intl.PluralRules` for the active locale selects them.
|
|
39
|
+
*/
|
|
40
|
+
export interface ThemeStringsPluralMap {
|
|
41
|
+
one?: string;
|
|
42
|
+
two?: string;
|
|
43
|
+
few?: string;
|
|
44
|
+
many?: string;
|
|
45
|
+
other: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ThemeStringsValue =
|
|
49
|
+
| string
|
|
50
|
+
| ThemeStringsPluralMap
|
|
51
|
+
| { [key: string]: ThemeStringsValue };
|
|
52
|
+
|
|
53
|
+
/** One dictionary: nested objects traversed by dot-path, or flat dotted keys. */
|
|
54
|
+
export type ThemeStringsDictionary = { [key: string]: ThemeStringsValue };
|
|
55
|
+
|
|
56
|
+
/** The bound translator both surfaces hand out. */
|
|
57
|
+
export type ThemeStringsFn = (
|
|
58
|
+
key: string,
|
|
59
|
+
vars?: ThemeStringsVars,
|
|
60
|
+
locale?: string | null,
|
|
61
|
+
) => string;
|
|
62
|
+
|
|
63
|
+
/** The kit's default English core dictionary (`locales/en.default.json`). */
|
|
64
|
+
export const defaultThemeStrings: ThemeStringsDictionary =
|
|
65
|
+
enDefault as ThemeStringsDictionary;
|
|
66
|
+
|
|
67
|
+
/** Maximum total key length — see the module docblock for the budget. */
|
|
68
|
+
export const THEME_STRING_KEY_MAX_LENGTH = 40;
|
|
69
|
+
|
|
70
|
+
/** Maximum value length. */
|
|
71
|
+
export const THEME_STRING_VALUE_MAX_LENGTH = 1000;
|
|
72
|
+
|
|
73
|
+
const PLURAL_FORMS = ['one', 'two', 'few', 'many', 'other'] as const;
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Canonical locale for string selection. Unknown/unparseable codes become
|
|
77
|
+
* English, mirroring `parseLocaleCode` (and the backend's unknown-locale
|
|
78
|
+
* rule). A well-formed code the runtime's ICU does not know also becomes
|
|
79
|
+
* English, so plural selection never silently follows the wrong language.
|
|
80
|
+
*/
|
|
81
|
+
export function canonicalThemeLocale(locale: string | null | undefined): string {
|
|
82
|
+
const code = parseLocaleCode(locale) ?? 'en';
|
|
83
|
+
try {
|
|
84
|
+
return Intl.PluralRules.supportedLocalesOf([code]).length > 0 ? code : 'en';
|
|
85
|
+
} catch {
|
|
86
|
+
return 'en';
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Text direction for a locale (`dir` attribute). There is no locale catalogue
|
|
92
|
+
* in the kit to read this from, so the base language decides: Arabic-script
|
|
93
|
+
* languages render `rtl`, everything else (including unknown codes) `ltr`.
|
|
94
|
+
*/
|
|
95
|
+
export function themeLocaleDir(locale: string | null | undefined): 'rtl' | 'ltr' {
|
|
96
|
+
const base = (parseLocaleCode(locale) ?? 'en').split('-')[0]?.toLowerCase() ?? 'en';
|
|
97
|
+
return RTL_BASE_LANGUAGES.has(base) ? 'rtl' : 'ltr';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const RTL_BASE_LANGUAGES: ReadonlySet<string> = new Set([
|
|
101
|
+
'ar', 'he', 'fa', 'ur', 'ps', 'sd', 'yi', 'ug', 'dv', 'ku',
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
105
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isPluralMap(value: unknown): value is ThemeStringsPluralMap {
|
|
109
|
+
if (!isRecord(value)) return false;
|
|
110
|
+
const keys = Object.keys(value);
|
|
111
|
+
return (
|
|
112
|
+
keys.length > 0 &&
|
|
113
|
+
keys.every((k) => (PLURAL_FORMS as readonly string[]).includes(k)) &&
|
|
114
|
+
keys.every((k) => typeof value[k] === 'string')
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Dot-path lookup that also accepts a flat literal dotted key. */
|
|
119
|
+
function lookupValue(
|
|
120
|
+
dictionary: ThemeStringsDictionary,
|
|
121
|
+
key: string,
|
|
122
|
+
): ThemeStringsValue | undefined {
|
|
123
|
+
if (Object.prototype.hasOwnProperty.call(dictionary, key)) {
|
|
124
|
+
return dictionary[key];
|
|
125
|
+
}
|
|
126
|
+
let node: ThemeStringsValue = dictionary;
|
|
127
|
+
for (const part of key.split('.')) {
|
|
128
|
+
if (!isRecord(node) || !Object.prototype.hasOwnProperty.call(node, part)) return undefined;
|
|
129
|
+
node = node[part] as ThemeStringsValue;
|
|
130
|
+
}
|
|
131
|
+
return node;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function pluralCount(vars: ThemeStringsVars | undefined): number | null {
|
|
135
|
+
const count = vars?.['count'];
|
|
136
|
+
return typeof count === 'number' && Number.isFinite(count) ? count : null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function selectPluralForm(
|
|
140
|
+
map: ThemeStringsPluralMap,
|
|
141
|
+
vars: ThemeStringsVars | undefined,
|
|
142
|
+
locale: string,
|
|
143
|
+
): string {
|
|
144
|
+
const count = pluralCount(vars);
|
|
145
|
+
if (count === null) return map.other;
|
|
146
|
+
let form: string;
|
|
147
|
+
try {
|
|
148
|
+
form = new Intl.PluralRules(locale).select(count);
|
|
149
|
+
} catch {
|
|
150
|
+
form = new Intl.PluralRules('en').select(count);
|
|
151
|
+
}
|
|
152
|
+
return map[form as keyof ThemeStringsPluralMap] ?? map.other;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Suffix convention: `key.one` … `key.other` looked up as (flat or nested) keys. */
|
|
156
|
+
function lookupSuffixed(
|
|
157
|
+
dictionary: ThemeStringsDictionary,
|
|
158
|
+
key: string,
|
|
159
|
+
vars: ThemeStringsVars | undefined,
|
|
160
|
+
locale: string,
|
|
161
|
+
): string | undefined {
|
|
162
|
+
const count = pluralCount(vars);
|
|
163
|
+
let form = 'other';
|
|
164
|
+
if (count !== null) {
|
|
165
|
+
try {
|
|
166
|
+
form = new Intl.PluralRules(locale).select(count);
|
|
167
|
+
} catch {
|
|
168
|
+
form = new Intl.PluralRules('en').select(count);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
for (const candidate of form === 'other' ? ['other'] : [form, 'other']) {
|
|
172
|
+
const value = lookupValue(dictionary, `${key}.${candidate}`);
|
|
173
|
+
if (typeof value === 'string') return interpolate(value, vars);
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `{var}` interpolation. A missing/nullish var keeps its `{placeholder}`
|
|
180
|
+
* (visible, greppable, never a crash); `{{` / `}}` escape to literal braces.
|
|
181
|
+
*/
|
|
182
|
+
export function interpolate(
|
|
183
|
+
template: string,
|
|
184
|
+
vars: ThemeStringsVars | undefined,
|
|
185
|
+
): string {
|
|
186
|
+
if (!template.includes('{') && !template.includes('}')) return template;
|
|
187
|
+
let out = '';
|
|
188
|
+
let i = 0;
|
|
189
|
+
while (i < template.length) {
|
|
190
|
+
const ch = template[i];
|
|
191
|
+
if (ch === '{') {
|
|
192
|
+
if (template[i + 1] === '{') {
|
|
193
|
+
out += '{';
|
|
194
|
+
i += 2;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const close = template.indexOf('}', i + 1);
|
|
198
|
+
const name = close === -1 ? '' : template.slice(i + 1, close);
|
|
199
|
+
if (close !== -1 && /^[A-Za-z0-9_]+$/.test(name)) {
|
|
200
|
+
const value = vars?.[name];
|
|
201
|
+
out += value === undefined || value === null ? template.slice(i, close + 1) : String(value);
|
|
202
|
+
i = close + 1;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
out += ch;
|
|
206
|
+
i += 1;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (ch === '}' && template[i + 1] === '}') {
|
|
210
|
+
out += '}';
|
|
211
|
+
i += 2;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
out += ch;
|
|
215
|
+
i += 1;
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const warnedKeys = new Set<string>();
|
|
221
|
+
|
|
222
|
+
function warnMissingKey(key: string): void {
|
|
223
|
+
if (typeof process !== 'undefined' && process.env['NODE_ENV'] === 'production') return;
|
|
224
|
+
if (warnedKeys.has(key)) return;
|
|
225
|
+
warnedKeys.add(key);
|
|
226
|
+
console.warn(`[theme-kit] missing theme string: "${key}" (no dictionary has it; rendered "")`);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Test-only: reset the warn-once set between cases. */
|
|
230
|
+
export function resetThemeStringsWarnings(): void {
|
|
231
|
+
warnedKeys.clear();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Pure lookup: first hit across the ordered dictionaries wins
|
|
236
|
+
* (override, theme locale, kit core locale — the caller appends
|
|
237
|
+
* `defaultThemeStrings` LAST). Absent everywhere: `''` in production, plus a
|
|
238
|
+
* once-per-key `console.warn` in development. Never the raw key.
|
|
239
|
+
*/
|
|
240
|
+
export function t(
|
|
241
|
+
dictionaries: ThemeStringsDictionary | Array<ThemeStringsDictionary | null | undefined>,
|
|
242
|
+
key: string,
|
|
243
|
+
vars?: ThemeStringsVars,
|
|
244
|
+
locale?: string | null,
|
|
245
|
+
): string {
|
|
246
|
+
const list = Array.isArray(dictionaries) ? dictionaries : [dictionaries];
|
|
247
|
+
const canonical = canonicalThemeLocale(locale);
|
|
248
|
+
for (const dictionary of list) {
|
|
249
|
+
if (!dictionary) continue;
|
|
250
|
+
const value = lookupValue(dictionary, key);
|
|
251
|
+
if (typeof value === 'string') return interpolate(value, vars);
|
|
252
|
+
if (isPluralMap(value)) return interpolate(selectPluralForm(value, vars, canonical), vars);
|
|
253
|
+
const suffixed = lookupSuffixed(dictionary, key, vars, canonical);
|
|
254
|
+
if (suffixed !== undefined) return suffixed;
|
|
255
|
+
}
|
|
256
|
+
warnMissingKey(key);
|
|
257
|
+
return '';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Per-locale loader the host supplies — usually a dynamic `import()`. */
|
|
261
|
+
export type ThemeStringsLoader = (
|
|
262
|
+
locale: string,
|
|
263
|
+
) => Promise<ThemeStringsDictionary | null | undefined>;
|
|
264
|
+
|
|
265
|
+
export interface ThemeStringsLoaders {
|
|
266
|
+
/** Merchant overrides for the active locale (highest precedence). */
|
|
267
|
+
override?: ThemeStringsLoader;
|
|
268
|
+
/** Active theme's `{locale}.json`. */
|
|
269
|
+
theme?: ThemeStringsLoader;
|
|
270
|
+
/** Kit core pack for the active locale. */
|
|
271
|
+
core?: ThemeStringsLoader;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface BoundThemeStrings {
|
|
275
|
+
/** Bound to the active locale's dictionaries (+ English default last). */
|
|
276
|
+
t: ThemeStringsFn;
|
|
277
|
+
/** Validated requested locale (`en` when unknown) — what loaders were read with. */
|
|
278
|
+
locale: string;
|
|
279
|
+
/** Ordered dictionaries the bound `t` reads (override, theme, core, default). */
|
|
280
|
+
dictionaries: ThemeStringsDictionary[];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Server-safe, provider-free loader contract. Reads ONLY the active locale
|
|
285
|
+
* (one locale crosses to the client — never every `{lang}.json`), merges
|
|
286
|
+
* override → theme → core → English default, and returns a bound `t` for
|
|
287
|
+
* props/closure use. A loader that throws or returns null (e.g. a missing
|
|
288
|
+
* locale file) simply drops out of the chain — never an error.
|
|
289
|
+
*/
|
|
290
|
+
export async function createThemeStrings(options?: {
|
|
291
|
+
locale?: string | null;
|
|
292
|
+
loaders?: ThemeStringsLoaders;
|
|
293
|
+
}): Promise<BoundThemeStrings> {
|
|
294
|
+
const requested = parseLocaleCode(options?.locale) ?? 'en';
|
|
295
|
+
const loaders = options?.loaders;
|
|
296
|
+
const dictionaries: ThemeStringsDictionary[] = [];
|
|
297
|
+
for (const load of [loaders?.override, loaders?.theme, loaders?.core]) {
|
|
298
|
+
if (!load) continue;
|
|
299
|
+
try {
|
|
300
|
+
const dictionary = await load(requested);
|
|
301
|
+
if (dictionary && isRecord(dictionary)) {
|
|
302
|
+
dictionaries.push(dictionary as ThemeStringsDictionary);
|
|
303
|
+
}
|
|
304
|
+
} catch {
|
|
305
|
+
// Missing locale file et al: fall through to the next dictionary.
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
dictionaries.push(defaultThemeStrings);
|
|
309
|
+
const active = requested;
|
|
310
|
+
return {
|
|
311
|
+
t: (key, vars, overrideLocale) =>
|
|
312
|
+
t(dictionaries, key, vars, overrideLocale ?? active),
|
|
313
|
+
locale: active,
|
|
314
|
+
dictionaries,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Locale-aware number formatting keyed by the active locale (unknown → English). */
|
|
319
|
+
export function formatThemeNumber(
|
|
320
|
+
value: number,
|
|
321
|
+
locale?: string | null,
|
|
322
|
+
options?: Intl.NumberFormatOptions,
|
|
323
|
+
): string {
|
|
324
|
+
return new Intl.NumberFormat(canonicalThemeLocale(locale), options).format(value);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Locale-aware date formatting keyed by the active locale (unknown → English). `''` if unparseable. */
|
|
328
|
+
export function formatThemeDate(
|
|
329
|
+
value: string | number | Date,
|
|
330
|
+
locale?: string | null,
|
|
331
|
+
options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric' },
|
|
332
|
+
): string {
|
|
333
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
334
|
+
if (Number.isNaN(date.getTime())) return '';
|
|
335
|
+
return new Intl.DateTimeFormat(canonicalThemeLocale(locale), options).format(date);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function normalizeMoneyCurrency(value: unknown): string {
|
|
339
|
+
return typeof value === 'string' && /^[A-Za-z]{3}$/.test(value.trim())
|
|
340
|
+
? value.trim().toUpperCase()
|
|
341
|
+
: 'NGN';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Locale-aware currency formatting keyed by the active locale (unknown → English). */
|
|
345
|
+
export function formatThemeMoney(
|
|
346
|
+
amount: number,
|
|
347
|
+
currency: unknown,
|
|
348
|
+
locale?: string | null,
|
|
349
|
+
options?: Omit<Intl.NumberFormatOptions, 'style' | 'currency'>,
|
|
350
|
+
): string {
|
|
351
|
+
return new Intl.NumberFormat(canonicalThemeLocale(locale), {
|
|
352
|
+
...options,
|
|
353
|
+
style: 'currency',
|
|
354
|
+
currency: normalizeMoneyCurrency(currency),
|
|
355
|
+
}).format(amount);
|
|
356
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
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 (slice K1): the backend 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 = 'kili-foods', 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: 'kili-foods', name: 'Kili Foods' } 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
|
+
});
|