@usequeek/theme-kit 0.1.20 → 0.1.21
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 +117 -0
- package/components/auth/auth-email-login-step.tsx +9 -7
- package/components/auth/auth-email-otp-step.tsx +9 -7
- package/components/auth/auth-email-otp-verify-step.tsx +8 -6
- package/components/auth/auth-email-register-step.tsx +10 -8
- package/components/auth/auth-signup-step.tsx +8 -6
- package/components/blog/category-filter.tsx +6 -5
- package/components/blog/pagination.tsx +8 -7
- package/components/blog/post-meta.tsx +15 -8
- package/components/blog/related-posts.tsx +5 -3
- package/components/blog/share-buttons.tsx +10 -8
- package/components/checkout/checkout-contact.tsx +6 -4
- package/components/checkout/default-checkout-shell.tsx +17 -15
- package/components/checkout/policy-links.tsx +5 -3
- package/hooks/use-auth-flow.tsx +18 -17
- package/index.ts +4 -0
- package/locales/en.default.json +45 -7
- package/package.json +1 -1
- package/strings/pseudo-locale.ts +264 -0
- package/strings/theme-strings.ts +178 -15
- package/tests/auth-email-errors.test.tsx +55 -0
- package/tests/display-format.test.ts +364 -2
- package/tests/shopper-chrome-strings.test.tsx +272 -0
- package/tests/theme-hook-placement.test.tsx +179 -0
- package/tests/theme-strings-pseudo.test.tsx +171 -0
- package/tests/theme-strings-typed.test.tsx +97 -0
- package/tests/theme-strings.test.tsx +8 -2
- package/tests/theme-typed-keys.fixture.ts +76 -0
- package/utils/format.ts +97 -9
- package/utils/locale.ts +52 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pseudo-locale QA (slice: typed keys + pseudo-locale QA helper).
|
|
3
|
+
*
|
|
4
|
+
* PURE AND SERVER-SAFE: this module imports nothing from `next/*` and nothing
|
|
5
|
+
* from React, so server components, Node tooling (theme-check), tests and the
|
|
6
|
+
* browser all share it (precedent: `utils/locale.ts`, `strings/theme-strings.ts`).
|
|
7
|
+
* Keep it that way.
|
|
8
|
+
*
|
|
9
|
+
* What it is: `pseudoLocalize(dictionary, options?)` returns a pseudo
|
|
10
|
+
* dictionary with the SAME keys and the SAME plural-map shape, where every
|
|
11
|
+
* string value is accent-mapped, ~30% longer, and wrapped in markers like
|
|
12
|
+
* `[!! … !!]`. `{placeholders}` and `{{`/`}}` escapes pass through
|
|
13
|
+
* untouched, keys are never touched.
|
|
14
|
+
*
|
|
15
|
+
* Two uses (both documented in the README):
|
|
16
|
+
* - Dev-only locale: mount the app with the pseudo dictionary as the
|
|
17
|
+
* `strings` prop. Every string that went through `t()` renders pseudo;
|
|
18
|
+
* any text that remains plain English next to pseudo text is a hard-coded
|
|
19
|
+
* (un-wrapped) string. Never ship pseudo to shoppers.
|
|
20
|
+
* - Tests: render a component with the pseudo dictionary, then run the
|
|
21
|
+
* English-leak scan (`findEnglishLeaks` / `assertNoEnglishLeak`) over the
|
|
22
|
+
* HTML. That scan is the reusable primitive theme tests (and the
|
|
23
|
+
* storefront, and theme-tools) import.
|
|
24
|
+
*
|
|
25
|
+
* English safety: this module never changes an English value — pseudo
|
|
26
|
+
* dictionaries are built on demand in dev/test and never enter a bundle.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import type { ThemeStringsDictionary, ThemeStringsValue } from './theme-strings';
|
|
30
|
+
|
|
31
|
+
export interface PseudoLocalizeOptions {
|
|
32
|
+
/** Marker prepended to every value. Default `'[!! '`. */
|
|
33
|
+
prefix?: string;
|
|
34
|
+
/** Marker appended to every value. Default `' !!]'`. */
|
|
35
|
+
suffix?: string;
|
|
36
|
+
/** Fraction of extra padding appended inside the markers. Default `0.3`. */
|
|
37
|
+
expansionRatio?: number;
|
|
38
|
+
/** Padding character. Default `'~'`. */
|
|
39
|
+
expansionChar?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Wrap the core in U+202B … U+202C (RTL embedding) for a visual RTL check.
|
|
42
|
+
* Default `false`. This is a spot-check aid, not bidi-correct output.
|
|
43
|
+
*/
|
|
44
|
+
rtl?: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const PSEUDO_PREFIX = '[!! ';
|
|
48
|
+
export const PSEUDO_SUFFIX = ' !!]';
|
|
49
|
+
export const PSEUDO_EXPANSION_RATIO = 0.3;
|
|
50
|
+
export const PSEUDO_EXPANSION_CHAR = '~';
|
|
51
|
+
|
|
52
|
+
const ACCENT_MAP: Record<string, string> = {
|
|
53
|
+
a: 'à', A: 'À', b: 'ƀ', B: 'Ɓ', c: 'ç', C: 'Ç', d: 'ď', D: 'Ď',
|
|
54
|
+
e: 'è', E: 'È', f: 'ƒ', F: 'Ƒ', g: 'ĝ', G: 'Ĝ', h: 'ĥ', H: 'Ĥ',
|
|
55
|
+
i: 'ì', I: 'Ì', j: 'ĵ', J: 'Ĵ', k: 'ķ', K: 'Ķ', l: 'ŀ', L: 'Ŀ',
|
|
56
|
+
m: 'ṁ', M: 'Ṁ', n: 'ñ', N: 'Ñ', o: 'ò', O: 'Ò', p: 'ṗ', P: 'Ṗ',
|
|
57
|
+
q: 'ǫ', Q: 'Ǫ', r: 'ŕ', R: 'Ŕ', s: 'š', S: 'Š', t: 'ţ', T: 'Ţ',
|
|
58
|
+
u: 'ù', U: 'Ù', v: 'ṽ', V: 'Ṽ', w: 'ŵ', W: 'Ŵ', x: 'ẋ', X: 'Ẋ',
|
|
59
|
+
y: 'ÿ', Y: 'Ÿ', z: 'ž', Z: 'Ž',
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const PLURAL_FORMS = ['one', 'two', 'few', 'many', 'other'];
|
|
63
|
+
|
|
64
|
+
/** Same leaf rule as `t()`'s runtime: all keys are plural forms, all values strings. */
|
|
65
|
+
function isPluralMapValue(value: unknown): value is Record<string, string> {
|
|
66
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
67
|
+
const keys = Object.keys(value);
|
|
68
|
+
return (
|
|
69
|
+
keys.length > 0 &&
|
|
70
|
+
keys.every((k) => PLURAL_FORMS.includes(k)) &&
|
|
71
|
+
keys.every((k) => typeof (value as Record<string, unknown>)[k] === 'string')
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function isPlaceholderName(name: string): boolean {
|
|
76
|
+
return /^[A-Za-z0-9_]+$/.test(name);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Pseudo-localize one string value. `{placeholder}` interpolations and
|
|
81
|
+
* `{{`/`}}` escapes are preserved byte-for-byte; only plain-text letters are
|
|
82
|
+
* accent-mapped. `''` stays `''` (an intentional empty renders nothing, so
|
|
83
|
+
* there is no node to mark).
|
|
84
|
+
*/
|
|
85
|
+
export function pseudoLocalizeText(value: string, options?: PseudoLocalizeOptions): string {
|
|
86
|
+
if (value === '') return '';
|
|
87
|
+
const prefix = options?.prefix ?? PSEUDO_PREFIX;
|
|
88
|
+
const suffix = options?.suffix ?? PSEUDO_SUFFIX;
|
|
89
|
+
const ratio = options?.expansionRatio ?? PSEUDO_EXPANSION_RATIO;
|
|
90
|
+
const expansionChar = options?.expansionChar ?? PSEUDO_EXPANSION_CHAR;
|
|
91
|
+
const rtl = options?.rtl ?? false;
|
|
92
|
+
|
|
93
|
+
let core = '';
|
|
94
|
+
let i = 0;
|
|
95
|
+
while (i < value.length) {
|
|
96
|
+
const ch = value[i];
|
|
97
|
+
if (ch === '{' && value[i + 1] === '{') {
|
|
98
|
+
core += '{{';
|
|
99
|
+
i += 2;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (ch === '}' && value[i + 1] === '}') {
|
|
103
|
+
core += '}}';
|
|
104
|
+
i += 2;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (ch === '{') {
|
|
108
|
+
const close = value.indexOf('}', i + 1);
|
|
109
|
+
const name = close === -1 ? '' : value.slice(i + 1, close);
|
|
110
|
+
if (close !== -1 && isPlaceholderName(name)) {
|
|
111
|
+
core += value.slice(i, close + 1);
|
|
112
|
+
i = close + 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
core += ACCENT_MAP[ch] ?? ch;
|
|
116
|
+
i += 1;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
core += /[A-Za-z]/.test(ch) ? (ACCENT_MAP[ch] ?? ch) : ch;
|
|
120
|
+
i += 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const padLength = Math.max(0, Math.round(core.length * ratio));
|
|
124
|
+
const expanded = core + expansionChar.repeat(padLength);
|
|
125
|
+
const inner = rtl ? '\u202B' + expanded + '\u202C' : expanded;
|
|
126
|
+
return `${prefix}${inner}${suffix}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function pseudoLocalizeValue(value: ThemeStringsValue, options?: PseudoLocalizeOptions): ThemeStringsValue {
|
|
130
|
+
if (typeof value === 'string') return pseudoLocalizeText(value, options);
|
|
131
|
+
if (isPluralMapValue(value)) {
|
|
132
|
+
const out: Record<string, string> = {};
|
|
133
|
+
for (const [form, template] of Object.entries(value)) {
|
|
134
|
+
out[form] = pseudoLocalizeText(template, options);
|
|
135
|
+
}
|
|
136
|
+
return out as ThemeStringsValue;
|
|
137
|
+
}
|
|
138
|
+
if (typeof value === 'object' && value !== null) {
|
|
139
|
+
const out: ThemeStringsDictionary = {};
|
|
140
|
+
for (const [k, v] of Object.entries(value as ThemeStringsDictionary)) {
|
|
141
|
+
out[k] = pseudoLocalizeValue(v, options);
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Pseudo-localize a whole dictionary. Keys (including flat literal dotted
|
|
150
|
+
* keys) are copied verbatim; nested objects recurse; plural maps keep their
|
|
151
|
+
* exact form keys with each template pseudo-localized.
|
|
152
|
+
*/
|
|
153
|
+
export function pseudoLocalize(
|
|
154
|
+
dictionary: ThemeStringsDictionary,
|
|
155
|
+
options?: PseudoLocalizeOptions,
|
|
156
|
+
): ThemeStringsDictionary {
|
|
157
|
+
const out: ThemeStringsDictionary = {};
|
|
158
|
+
for (const [key, value] of Object.entries(dictionary)) {
|
|
159
|
+
out[key] = pseudoLocalizeValue(value, options);
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** True when the rendered text carries this run's pseudo markers. */
|
|
165
|
+
export function isPseudoLocalized(text: string, options?: PseudoLocalizeOptions): boolean {
|
|
166
|
+
const prefix = options?.prefix ?? PSEUDO_PREFIX;
|
|
167
|
+
const suffix = options?.suffix ?? PSEUDO_SUFFIX;
|
|
168
|
+
const trimmed = text.trim();
|
|
169
|
+
return (
|
|
170
|
+
trimmed.length >= prefix.length + suffix.length &&
|
|
171
|
+
trimmed.startsWith(prefix) &&
|
|
172
|
+
trimmed.endsWith(suffix)
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface EnglishLeakScanOptions {
|
|
177
|
+
/**
|
|
178
|
+
* Visible strings that are English by design (brand names, codes) and must
|
|
179
|
+
* not be reported. Exact strings or patterns, matched against the trimmed
|
|
180
|
+
* node text.
|
|
181
|
+
*/
|
|
182
|
+
allowlist?: Array<string | RegExp>;
|
|
183
|
+
/**
|
|
184
|
+
* Also scan user-visible attributes (`aria-label`, `aria-valuetext`,
|
|
185
|
+
* `placeholder`, `title`, `alt`). Default `true` — hard-coded aria copy is
|
|
186
|
+
* the same bug class as hard-coded text nodes.
|
|
187
|
+
*/
|
|
188
|
+
includeAttributes?: boolean;
|
|
189
|
+
/** Marker options used for the pseudo run being scanned. */
|
|
190
|
+
pseudo?: PseudoLocalizeOptions;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function decodeEntities(value: string): string {
|
|
194
|
+
return value
|
|
195
|
+
.replace(/&/g, '&')
|
|
196
|
+
.replace(/</g, '<')
|
|
197
|
+
.replace(/>/g, '>')
|
|
198
|
+
.replace(/"/g, '"')
|
|
199
|
+
.replace(/'|'/g, "'")
|
|
200
|
+
.replace(/ /g, ' ');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function visibleTexts(html: string, includeAttributes: boolean): string[] {
|
|
204
|
+
const found: string[] = [];
|
|
205
|
+
let text = html
|
|
206
|
+
.replace(/<script[\s\S]*?<\/script>/gi, ' ')
|
|
207
|
+
.replace(/<style[\s\S]*?<\/style>/gi, ' ')
|
|
208
|
+
.replace(/<!--[\s\S]*?-->/g, ' ');
|
|
209
|
+
if (includeAttributes) {
|
|
210
|
+
for (const match of text.matchAll(
|
|
211
|
+
/(?:aria-label|aria-valuetext|placeholder|title|alt)\s*=\s*"([^"]*)"/gi,
|
|
212
|
+
)) {
|
|
213
|
+
const attr = decodeEntities(match[1] ?? '').trim().replace(/\s+/g, ' ');
|
|
214
|
+
if (attr) found.push(attr);
|
|
215
|
+
}
|
|
216
|
+
for (const match of text.matchAll(
|
|
217
|
+
/(?:aria-label|aria-valuetext|placeholder|title|alt)\s*=\s*'([^']*)'/gi,
|
|
218
|
+
)) {
|
|
219
|
+
const attr = decodeEntities(match[1] ?? '').trim().replace(/\s+/g, ' ');
|
|
220
|
+
if (attr) found.push(attr);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
const stripped = text.replace(/<[^>]*>/g, '\u0000');
|
|
224
|
+
for (const part of stripped.split('\u0000')) {
|
|
225
|
+
const node = decodeEntities(part).trim().replace(/\s+/g, ' ');
|
|
226
|
+
if (node) found.push(node);
|
|
227
|
+
}
|
|
228
|
+
return found;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The English-leak scan primitive: every visible text node of the rendered
|
|
233
|
+
* HTML that is NOT pseudo-localized (and not allowlisted, and containing at
|
|
234
|
+
* least one ASCII letter so numbers/symbols never report) is a hard-coded
|
|
235
|
+
* English string. Returns the offending node texts (trimmed, in document
|
|
236
|
+
* order). Pure — theme tests render with the pseudo dictionary, then call
|
|
237
|
+
* this (or `assertNoEnglishLeak`).
|
|
238
|
+
*/
|
|
239
|
+
export function findEnglishLeaks(html: string, options?: EnglishLeakScanOptions): string[] {
|
|
240
|
+
const allowlist = options?.allowlist ?? [];
|
|
241
|
+
const includeAttributes = options?.includeAttributes ?? true;
|
|
242
|
+
return visibleTexts(html, includeAttributes).filter((node) => {
|
|
243
|
+
if (isPseudoLocalized(node, options?.pseudo)) return false;
|
|
244
|
+
if (!/[A-Za-z]/.test(node)) return false;
|
|
245
|
+
if (allowlist.some((rule) => (typeof rule === 'string' ? rule === node : rule.test(node)))) {
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
return true;
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Throw listing every leaked node, or return silently when the render is clean. */
|
|
253
|
+
export function assertNoEnglishLeak(html: string, options?: EnglishLeakScanOptions): void {
|
|
254
|
+
const leaks = findEnglishLeaks(html, options);
|
|
255
|
+
if (leaks.length > 0) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`[theme-kit] English leak: ${leaks.length} visible string(s) are not pseudo-localized:\n` +
|
|
258
|
+
leaks
|
|
259
|
+
.slice(0, 20)
|
|
260
|
+
.map((leak) => ` - ${JSON.stringify(leak)}`)
|
|
261
|
+
.join('\n'),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
}
|
package/strings/theme-strings.ts
CHANGED
|
@@ -26,7 +26,12 @@
|
|
|
26
26
|
* the key leaves 23 for the slug plus the dot separator. Values <= 1000 chars.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
ENGLISH_DISPLAY_LOCALE,
|
|
31
|
+
parseLocaleCode,
|
|
32
|
+
resolveIntlLocale,
|
|
33
|
+
resolveSupportedLocale,
|
|
34
|
+
} from '../utils/locale';
|
|
30
35
|
import enDefault from '../locales/en.default.json';
|
|
31
36
|
|
|
32
37
|
/** Interpolation variables: `{name}` in a value. `count` drives plurals. */
|
|
@@ -73,10 +78,19 @@ export const THEME_STRING_VALUE_MAX_LENGTH = 1000;
|
|
|
73
78
|
const PLURAL_FORMS = ['one', 'two', 'few', 'many', 'other'] as const;
|
|
74
79
|
|
|
75
80
|
/**
|
|
76
|
-
* Canonical locale for
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
81
|
+
* Canonical locale for PLURAL SELECTION ONLY (used by `t()`).
|
|
82
|
+
*
|
|
83
|
+
* This returns a bare `'en'` tag for unknown codes, and bare `'en'` is a
|
|
84
|
+
* US-order Intl tag (`February 15, 2026`). NEVER pass its result to
|
|
85
|
+
* `Intl.DateTimeFormat` / `Intl.NumberFormat` / `Intl.RelativeTimeFormat`,
|
|
86
|
+
* `toLocale*`, or any date/number/money/relative-time formatter — that would
|
|
87
|
+
* reintroduce the US-order defect the founder banned for English stores.
|
|
88
|
+
* Formatters must route through `resolveIntlLocale` (en* → en-NG) instead.
|
|
89
|
+
*
|
|
90
|
+
* Unknown/unparseable codes become English, mirroring `parseLocaleCode` (and
|
|
91
|
+
* the backend's unknown-locale rule). A well-formed code the runtime's ICU
|
|
92
|
+
* does not know also becomes English, so plural selection never silently
|
|
93
|
+
* follows the wrong language.
|
|
80
94
|
*/
|
|
81
95
|
export function canonicalThemeLocale(locale: string | null | undefined): string {
|
|
82
96
|
const code = parseLocaleCode(locale) ?? 'en';
|
|
@@ -315,16 +329,48 @@ export async function createThemeStrings(options?: {
|
|
|
315
329
|
};
|
|
316
330
|
}
|
|
317
331
|
|
|
318
|
-
/**
|
|
332
|
+
/**
|
|
333
|
+
* Pick the Intl tag for one theme formatting call: the storefront locale
|
|
334
|
+
* through the single canonical mapping (`resolveIntlLocale` — English lands
|
|
335
|
+
* on `ENGLISH_DISPLAY_LOCALE`, never bare `en` with its US order), or
|
|
336
|
+
* English when the runtime's ICU lacks the locale. Never throws.
|
|
337
|
+
*/
|
|
338
|
+
function themeFormatTag(
|
|
339
|
+
locale: string | null | undefined,
|
|
340
|
+
supportedLocalesOf: (locales: string[]) => readonly string[],
|
|
341
|
+
): string {
|
|
342
|
+
return resolveSupportedLocale(resolveIntlLocale(locale), supportedLocalesOf);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Locale-aware number formatting keyed by the active locale (unknown → English). Never throws. */
|
|
319
346
|
export function formatThemeNumber(
|
|
320
347
|
value: number,
|
|
321
348
|
locale?: string | null,
|
|
322
349
|
options?: Intl.NumberFormatOptions,
|
|
323
350
|
): string {
|
|
324
|
-
|
|
351
|
+
const tag = themeFormatTag(locale, (l) => Intl.NumberFormat.supportedLocalesOf(l));
|
|
352
|
+
try {
|
|
353
|
+
return new Intl.NumberFormat(tag, options).format(value);
|
|
354
|
+
} catch {
|
|
355
|
+
try {
|
|
356
|
+
return new Intl.NumberFormat(ENGLISH_DISPLAY_LOCALE, options).format(value);
|
|
357
|
+
} catch {
|
|
358
|
+
return String(value);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
325
361
|
}
|
|
326
362
|
|
|
327
|
-
/**
|
|
363
|
+
/**
|
|
364
|
+
* Locale-aware date formatting keyed by the active locale (unknown →
|
|
365
|
+
* English). `''` if unparseable. Never throws.
|
|
366
|
+
*
|
|
367
|
+
* THE helper themes call instead of hardcoding 'en-GB' in their own date
|
|
368
|
+
* formatting: pass the storefront locale (client: `useStorefrontLocale()`; server: the
|
|
369
|
+
* request locale the host already holds) and the same Intl options — English
|
|
370
|
+
* shoppers see the unchanged British/Nigerian order ("2 October 2026"),
|
|
371
|
+
* other shoppers see their own locale. A Node whose ICU lacks the locale
|
|
372
|
+
* falls back to English, never an exception.
|
|
373
|
+
*/
|
|
328
374
|
export function formatThemeDate(
|
|
329
375
|
value: string | number | Date,
|
|
330
376
|
locale?: string | null,
|
|
@@ -332,7 +378,16 @@ export function formatThemeDate(
|
|
|
332
378
|
): string {
|
|
333
379
|
const date = value instanceof Date ? value : new Date(value);
|
|
334
380
|
if (Number.isNaN(date.getTime())) return '';
|
|
335
|
-
|
|
381
|
+
const tag = themeFormatTag(locale, (l) => Intl.DateTimeFormat.supportedLocalesOf(l));
|
|
382
|
+
try {
|
|
383
|
+
return new Intl.DateTimeFormat(tag, options).format(date);
|
|
384
|
+
} catch {
|
|
385
|
+
try {
|
|
386
|
+
return new Intl.DateTimeFormat(ENGLISH_DISPLAY_LOCALE, options).format(date);
|
|
387
|
+
} catch {
|
|
388
|
+
return '';
|
|
389
|
+
}
|
|
390
|
+
}
|
|
336
391
|
}
|
|
337
392
|
|
|
338
393
|
function normalizeMoneyCurrency(value: unknown): string {
|
|
@@ -341,16 +396,124 @@ function normalizeMoneyCurrency(value: unknown): string {
|
|
|
341
396
|
: 'NGN';
|
|
342
397
|
}
|
|
343
398
|
|
|
344
|
-
/**
|
|
399
|
+
/**
|
|
400
|
+
* OPTIONAL typed keys (additive — the untyped `t(key: string, …)` above is
|
|
401
|
+
* untouched, so every existing caller keeps compiling).
|
|
402
|
+
*
|
|
403
|
+
* `ThemeStringKey<typeof strings>` derives the key union from a theme's
|
|
404
|
+
* English dictionary object: nested objects flatten to dotted keys
|
|
405
|
+
* (`{ cart: { title: '…' } }` → `'cart.title'`), a plural map
|
|
406
|
+
* (`{ one, …, other }`) counts as ONE key (`'items.count'`, never
|
|
407
|
+
* `'items.count.one'`), and a flat literal dotted key (`'a.b.c'`) stays one
|
|
408
|
+
* key. A broad `ThemeStringsDictionary` (index signature) collapses to
|
|
409
|
+
* `string` — i.e. untyped — by design.
|
|
410
|
+
*
|
|
411
|
+
* Pair with `createThemeT` for a drop-in typed binding:
|
|
412
|
+
*
|
|
413
|
+
* ```ts
|
|
414
|
+
* import enDefault from './locales/en.default.json';
|
|
415
|
+
* const { t } = await createThemeStrings({ locale });
|
|
416
|
+
* const tt = createThemeT<typeof enDefault>(t);
|
|
417
|
+
* tt('cart.title'); // ok
|
|
418
|
+
* // tt('cart.titl'); // type error: typo key fails
|
|
419
|
+
* ```
|
|
420
|
+
*/
|
|
421
|
+
export type ThemePluralForm = 'one' | 'two' | 'few' | 'many' | 'other';
|
|
422
|
+
|
|
423
|
+
type IsThemePluralMap<V> = V extends { other: string }
|
|
424
|
+
? Exclude<keyof V, ThemePluralForm> extends never
|
|
425
|
+
? true
|
|
426
|
+
: false
|
|
427
|
+
: false;
|
|
428
|
+
|
|
429
|
+
export type ThemeStringKey<T, Prefix extends string = ''> = string extends Extract<
|
|
430
|
+
keyof T,
|
|
431
|
+
string
|
|
432
|
+
>
|
|
433
|
+
? string
|
|
434
|
+
: {
|
|
435
|
+
[K in Extract<keyof T, string>]: T[K] extends string
|
|
436
|
+
? `${Prefix}${K}`
|
|
437
|
+
: IsThemePluralMap<T[K]> extends true
|
|
438
|
+
? `${Prefix}${K}`
|
|
439
|
+
: T[K] extends Record<string, unknown>
|
|
440
|
+
? ThemeStringKey<T[K], `${Prefix}${K}.`>
|
|
441
|
+
: `${Prefix}${K}`;
|
|
442
|
+
}[Extract<keyof T, string>];
|
|
443
|
+
|
|
444
|
+
/** The typed binding `createThemeT` hands out: same call shape as `t`. */
|
|
445
|
+
export type TypedThemeStringsFn<TKey extends string = string> = (
|
|
446
|
+
key: TKey,
|
|
447
|
+
vars?: ThemeStringsVars,
|
|
448
|
+
locale?: string | null,
|
|
449
|
+
) => string;
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Wrap any bound `t` (server `createThemeStrings` or client
|
|
453
|
+
* `useThemeStrings()`) so only keys of `TDictionary` compile. Pass the
|
|
454
|
+
* theme's English dictionary type explicitly:
|
|
455
|
+
* `createThemeT<typeof enDefault>(t)`. Pass a broad
|
|
456
|
+
* `ThemeStringsDictionary` and the result stays `(key: string, …)` — the
|
|
457
|
+
* untyped behaviour, unchanged.
|
|
458
|
+
*/
|
|
459
|
+
export function createThemeT<TDictionary>(
|
|
460
|
+
translate: ThemeStringsFn,
|
|
461
|
+
): TypedThemeStringsFn<ThemeStringKey<TDictionary>> {
|
|
462
|
+
return translate as TypedThemeStringsFn<ThemeStringKey<TDictionary>>;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Locale-aware currency formatting keyed by the active locale (unknown → English). Never throws. */
|
|
345
466
|
export function formatThemeMoney(
|
|
346
467
|
amount: number,
|
|
347
468
|
currency: unknown,
|
|
348
469
|
locale?: string | null,
|
|
349
470
|
options?: Omit<Intl.NumberFormatOptions, 'style' | 'currency'>,
|
|
350
471
|
): string {
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
472
|
+
const code = normalizeMoneyCurrency(currency);
|
|
473
|
+
const tag = themeFormatTag(locale, (l) => Intl.NumberFormat.supportedLocalesOf(l));
|
|
474
|
+
try {
|
|
475
|
+
return new Intl.NumberFormat(tag, {
|
|
476
|
+
...options,
|
|
477
|
+
style: 'currency',
|
|
478
|
+
currency: code,
|
|
479
|
+
}).format(amount);
|
|
480
|
+
} catch {
|
|
481
|
+
try {
|
|
482
|
+
return new Intl.NumberFormat(ENGLISH_DISPLAY_LOCALE, {
|
|
483
|
+
...options,
|
|
484
|
+
style: 'currency',
|
|
485
|
+
currency: code,
|
|
486
|
+
}).format(amount);
|
|
487
|
+
} catch {
|
|
488
|
+
return `${code} ${amount}`;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Locale-aware relative time keyed by the active locale (unknown → English):
|
|
495
|
+
* `formatThemeRelativeTime(-1, 'day', 'fr')` → `"hier"`. `numeric: 'auto'`
|
|
496
|
+
* by default so whole days read as words where the language has them. Never
|
|
497
|
+
* throws — a missing ICU locale falls back to English, a bad unit to
|
|
498
|
+
* `"<value> <unit>"`.
|
|
499
|
+
*/
|
|
500
|
+
export function formatThemeRelativeTime(
|
|
501
|
+
value: number,
|
|
502
|
+
unit: Intl.RelativeTimeFormatUnit = 'day',
|
|
503
|
+
locale?: string | null,
|
|
504
|
+
options?: Intl.RelativeTimeFormatOptions,
|
|
505
|
+
): string {
|
|
506
|
+
const tag = themeFormatTag(locale, (l) => Intl.RelativeTimeFormat.supportedLocalesOf(l));
|
|
507
|
+
try {
|
|
508
|
+
return new Intl.RelativeTimeFormat(tag, { numeric: 'auto', ...options }).format(value, unit);
|
|
509
|
+
} catch {
|
|
510
|
+
try {
|
|
511
|
+
return new Intl.RelativeTimeFormat(ENGLISH_DISPLAY_LOCALE, {
|
|
512
|
+
numeric: 'auto',
|
|
513
|
+
...options,
|
|
514
|
+
}).format(value, unit);
|
|
515
|
+
} catch {
|
|
516
|
+
return `${value} ${unit}`;
|
|
517
|
+
}
|
|
518
|
+
}
|
|
356
519
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth error English pins (checkout / auth slice) — S4 drift guard.
|
|
3
|
+
*
|
|
4
|
+
* `mapEmailError` maps SDK failure codes to `t('auth.error.*')` strings, and
|
|
5
|
+
* three more error keys are set directly in the provider. Every English value
|
|
6
|
+
* below is resolved through the REAL default dictionary, so renaming a key or
|
|
7
|
+
* changing any English value fails here. S7 proof included.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, expect, it } from 'vitest';
|
|
10
|
+
import { QueekSdkError } from '@queekai/client-sdk';
|
|
11
|
+
import { t, defaultThemeStrings } from '../strings/theme-strings';
|
|
12
|
+
import { mapEmailError } from '../hooks/use-auth-flow';
|
|
13
|
+
|
|
14
|
+
const tt = (key: string): string => t(defaultThemeStrings, key);
|
|
15
|
+
|
|
16
|
+
function sdkError(code: string, message: string): QueekSdkError {
|
|
17
|
+
return new QueekSdkError(message, code, 400);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('mapEmailError code -> English pins', () => {
|
|
21
|
+
it.each([
|
|
22
|
+
['invalid_credentials', 'Wrong email or password. Please try again.'],
|
|
23
|
+
['login_mode_mismatch', 'This account uses a different sign-in method (Google or phone).'],
|
|
24
|
+
['account_already_exists', 'Email already registered. Please sign in instead.'],
|
|
25
|
+
['phone_already_exists', 'Phone number already in use.'],
|
|
26
|
+
['otp_resend_too_soon', 'Please wait before requesting another code.'],
|
|
27
|
+
['invalid_otp', 'Incorrect code. Please try again.'],
|
|
28
|
+
] as Array<[string, string]>)('code %s maps to its exact English', (code, english) => {
|
|
29
|
+
expect(mapEmailError(sdkError(code, 'server message'), tt)).toBe(english);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('passes an unknown-code server message through untouched', () => {
|
|
33
|
+
expect(mapEmailError(sdkError('something_else', 'Server says no.'), tt)).toBe('Server says no.');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('falls back to generic English when the server message is empty', () => {
|
|
37
|
+
expect(mapEmailError(sdkError('something_else', ''), tt)).toBe('Something went wrong. Please try again.');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('falls back to generic English for non-SDK failures', () => {
|
|
41
|
+
expect(mapEmailError(new Error('boom'), tt)).toBe('Something went wrong. Please try again.');
|
|
42
|
+
expect(mapEmailError('boom', tt)).toBe('Something went wrong. Please try again.');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('direct-set auth.error.* English pins', () => {
|
|
47
|
+
it.each([
|
|
48
|
+
['auth.error.firstname', 'Please enter your first name.'],
|
|
49
|
+
['auth.error.register', 'Registration failed. Try again.'],
|
|
50
|
+
['auth.error.google', 'Could not connect to Google sign-in. Please try again.'],
|
|
51
|
+
['auth.error.generic', 'Something went wrong. Please try again.'],
|
|
52
|
+
] as Array<[string, string]>)('%s resolves to its exact English', (key, english) => {
|
|
53
|
+
expect(tt(key)).toBe(english);
|
|
54
|
+
});
|
|
55
|
+
});
|