@usequeek/theme-kit 0.1.17 → 0.1.19
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 +51 -0
- package/components/cart/default-cart-shell.tsx +23 -21
- package/index.ts +3 -0
- package/locales/en.default.json +138 -0
- package/package.json +2 -1
- package/provider.tsx +67 -1
- package/strings/theme-strings.ts +356 -0
- package/tests/theme-strings-client.test.tsx +310 -0
- package/tests/theme-strings.test.tsx +398 -0
- package/theme-context.tsx +10 -0
- package/types/theme.ts +21 -0
|
@@ -0,0 +1,398 @@
|
|
|
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
|
+
// Same grammar as the backend overlay (ThemeStringTranslatable::KEY_PATTERN suffix) and theme-check
|
|
250
|
+
// `theme/locale-key-naming`: segments may start with a digit (e.g. `auth.2fa.code`).
|
|
251
|
+
expect(key, `grammar: ${key}`).toMatch(/^[a-z0-9]+(\.[a-z0-9]+)*$/);
|
|
252
|
+
expect(key.length, `budget: ${key}`).toBeLessThanOrEqual(THEME_STRING_KEY_MAX_LENGTH);
|
|
253
|
+
}
|
|
254
|
+
// `<theme-slug>.<key>` must fit varchar(64): 40 + 1 + 23.
|
|
255
|
+
expect(Math.max(...pairs.map(([k]) => k.length))).toBeLessThanOrEqual(40);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('every value fits the 1000-char budget', () => {
|
|
259
|
+
for (const [key, value] of pairs) {
|
|
260
|
+
expect(value.length, `value: ${key}`).toBeLessThanOrEqual(THEME_STRING_VALUE_MAX_LENGTH);
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('matches the runtime default export', () => {
|
|
265
|
+
expect(defaultThemeStrings).toEqual(enDefault);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
describe('per-locale loading gate (decision 5)', () => {
|
|
270
|
+
function sourceFiles(dir: string, out: string[] = []): string[] {
|
|
271
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
272
|
+
if (entry.name === 'node_modules') continue;
|
|
273
|
+
const full = join(dir, entry.name);
|
|
274
|
+
if (entry.isDirectory()) sourceFiles(full, out);
|
|
275
|
+
else if (/\.tsx?$/.test(entry.name)) out.push(full);
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Import specifiers only (static `from`/`import` and dynamic `import()`). */
|
|
281
|
+
function importSpecifiers(source: string): string[] {
|
|
282
|
+
const out: string[] = [];
|
|
283
|
+
for (const match of source.matchAll(/from\s+['"]([^'"]+)['"]/g)) out.push(match[1]);
|
|
284
|
+
for (const match of source.matchAll(/import\s*\(\s*['"`]([^'"`]+)['"`]/g)) out.push(match[1]);
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
it('no client-reachable module statically imports a non-default locale file', () => {
|
|
289
|
+
const offenders: string[] = [];
|
|
290
|
+
for (const file of sourceFiles(ROOT)) {
|
|
291
|
+
const source = readFileSync(file, 'utf-8');
|
|
292
|
+
for (const spec of importSpecifiers(source)) {
|
|
293
|
+
if (spec.includes('locales/') && !spec.endsWith('locales/en.default.json')) {
|
|
294
|
+
offenders.push(`${file}: ${spec}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
expect(offenders).toEqual([]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('the kit never dynamic-imports the locales dir (only the host does, per locale)', () => {
|
|
302
|
+
const offenders: string[] = [];
|
|
303
|
+
for (const file of sourceFiles(ROOT)) {
|
|
304
|
+
if (file.endsWith('.test.ts') || file.endsWith('.test.tsx')) continue;
|
|
305
|
+
const source = readFileSync(file, 'utf-8');
|
|
306
|
+
if (/import\s*\(\s*[`'"][^`'"]*locales\//.test(source)) {
|
|
307
|
+
offenders.push(file);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
expect(offenders).toEqual([]);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('only the English default may be statically imported (exactly one locale ships)', () => {
|
|
314
|
+
const importers: string[] = [];
|
|
315
|
+
for (const file of sourceFiles(ROOT)) {
|
|
316
|
+
if (file.endsWith('.test.ts') || file.endsWith('.test.tsx')) continue;
|
|
317
|
+
const source = readFileSync(file, 'utf-8');
|
|
318
|
+
if (source.includes('locales/en.default.json')) importers.push(file);
|
|
319
|
+
}
|
|
320
|
+
expect(importers).toEqual([join(ROOT, 'strings', 'theme-strings.ts')]);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('the pure/server-safe module imports neither next/* nor React', () => {
|
|
324
|
+
const source = readFileSync(join(ROOT, 'strings', 'theme-strings.ts'), 'utf-8');
|
|
325
|
+
expect(source).not.toMatch(/from ['"]next\//);
|
|
326
|
+
expect(source).not.toMatch(/from ['"]react['"]/);
|
|
327
|
+
expect(source).not.toContain("'use client'");
|
|
328
|
+
expect(existsSync(join(ROOT, 'locales', 'en.default.json'))).toBe(true);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe('server surface: bound t with NO provider (decision 4a)', () => {
|
|
333
|
+
function Greeting({ bound, name }: { bound: BoundThemeStrings; name: string }): JSX.Element {
|
|
334
|
+
return (
|
|
335
|
+
<main>
|
|
336
|
+
<h1>{bound.t('cart.title')}</h1>
|
|
337
|
+
<p>{bound.t('cart.empty.message')}</p>
|
|
338
|
+
<p>{bound.t('cart.item.each', { price: name })}</p>
|
|
339
|
+
</main>
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function renderFor(locale: string | null): Promise<{ bound: BoundThemeStrings; html: string }> {
|
|
344
|
+
const bound = await createThemeStrings({
|
|
345
|
+
locale,
|
|
346
|
+
loaders: {
|
|
347
|
+
theme: async (active) => (active === 'fr' ? FR_CORE : null),
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
return { bound, html: renderToStaticMarkup(<Greeting bound={bound} name="5 000 F" />) };
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
it('renders EN from defaults with no provider in the tree', async () => {
|
|
354
|
+
const { html } = await renderFor(null);
|
|
355
|
+
expect(html).toContain('<h1>Your cart</h1>');
|
|
356
|
+
expect(html).toContain('Your cart is empty.');
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('renders FR from the active locale only', async () => {
|
|
360
|
+
const { bound, html } = await renderFor('fr');
|
|
361
|
+
expect(bound.locale).toBe('fr');
|
|
362
|
+
expect(html).toContain('<h1>Votre panier</h1>');
|
|
363
|
+
expect(html).toContain('Votre panier est vide.');
|
|
364
|
+
// Key missing from the FR pack falls back to the English VALUE.
|
|
365
|
+
expect(html).toContain('5 000 F each');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('renders AR with English values and an rtl dir', async () => {
|
|
369
|
+
const { bound, html } = await renderFor('ar');
|
|
370
|
+
expect(bound.locale).toBe('ar');
|
|
371
|
+
expect(html).toContain('<h1>Your cart</h1>');
|
|
372
|
+
expect(themeLocaleDir(bound.locale)).toBe('rtl');
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
describe('Intl probe (report only — en fr es pt ar sw ha yo ig zh de pcm)', () => {
|
|
377
|
+
const LOCALES = ['en', 'fr', 'es', 'pt', 'ar', 'sw', 'ha', 'yo', 'ig', 'zh', 'de', 'pcm'];
|
|
378
|
+
|
|
379
|
+
it('records native support vs silent fallback on this Node runtime', () => {
|
|
380
|
+
const rows = LOCALES.map((locale) => ({
|
|
381
|
+
locale,
|
|
382
|
+
plural: Intl.PluralRules.supportedLocalesOf([locale]).length > 0 ? 'native' : 'FALLBACK',
|
|
383
|
+
number: Intl.NumberFormat.supportedLocalesOf([locale]).length > 0 ? 'native' : 'FALLBACK',
|
|
384
|
+
date: Intl.DateTimeFormat.supportedLocalesOf([locale]).length > 0 ? 'native' : 'FALLBACK',
|
|
385
|
+
}));
|
|
386
|
+
// eslint-disable-next-line no-console
|
|
387
|
+
console.log(`[intl-probe] node ${process.version}\n${rows.map((r) => `${r.locale}: plural=${r.plural} number=${r.number} date=${r.date}`).join('\n')}`);
|
|
388
|
+
|
|
389
|
+
// Every locale still formats without throwing (helpers fall back to English).
|
|
390
|
+
for (const locale of LOCALES) {
|
|
391
|
+
expect(() => formatThemeNumber(1234.5, locale)).not.toThrow();
|
|
392
|
+
expect(() => formatThemeDate('2026-02-14', locale)).not.toThrow();
|
|
393
|
+
expect(() => formatThemeMoney(5000, 'NGN', locale)).not.toThrow();
|
|
394
|
+
expect(t([defaultThemeStrings], 'cart.title', {}, locale)).toBe('Your cart');
|
|
395
|
+
}
|
|
396
|
+
expect(rows).toHaveLength(12);
|
|
397
|
+
});
|
|
398
|
+
});
|
package/theme-context.tsx
CHANGED
|
@@ -27,3 +27,13 @@ export function useTheme(): ThemeModule {
|
|
|
27
27
|
|
|
28
28
|
return theme;
|
|
29
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Null outside a theme provider (kit chrome such as the cart shell renders
|
|
33
|
+
* under `StorefrontProvider` with no theme mounted). `useThemeStrings`
|
|
34
|
+
* reads the mounted theme's `manifest.strings` through this, so a missing
|
|
35
|
+
* theme simply contributes no dictionary instead of throwing.
|
|
36
|
+
*/
|
|
37
|
+
export function useOptionalTheme(): ThemeModule | null {
|
|
38
|
+
return useContext(ThemeContext);
|
|
39
|
+
}
|
package/types/theme.ts
CHANGED
|
@@ -6,6 +6,7 @@ import type { MenuItem } from './menu';
|
|
|
6
6
|
import type { BlogCategory, Collection, Page, Pagination, Post } from './page';
|
|
7
7
|
import type { MetaobjectEntry, Product } from './product';
|
|
8
8
|
import type { DesignTokenAxisPath, DesignTokens, StorefrontConfig, VendorProfile } from './vendor';
|
|
9
|
+
import type { ThemeStringsDictionary } from '../strings/theme-strings';
|
|
9
10
|
|
|
10
11
|
export interface HomePageProps {
|
|
11
12
|
blocks: Block[];
|
|
@@ -350,6 +351,26 @@ export interface ThemeManifest {
|
|
|
350
351
|
* injector's own hardcoded defaults (`lib/core/utils/brand.ts`).
|
|
351
352
|
*/
|
|
352
353
|
tokens?: DesignTokens;
|
|
354
|
+
/**
|
|
355
|
+
* The theme's English default dictionary — the theme's own English
|
|
356
|
+
* locale file (authored as `en.default.json` in the theme's `locales/`
|
|
357
|
+
* dir and imported as an object) referenced here.
|
|
358
|
+
* Same shape as the kit core English default
|
|
359
|
+
* (nested objects by dot-path, or flat dotted keys; CLDR plural maps
|
|
360
|
+
* allowed); keys follow the shared grammar (dotted lowercase, <= 40
|
|
361
|
+
* chars) because the backend overlay stores `<theme-slug>.<key>` in a
|
|
362
|
+
* varchar(64).
|
|
363
|
+
*
|
|
364
|
+
* The kit layers it between the host-loaded locale dictionaries and the
|
|
365
|
+
* kit core English, so ANY host that mounts the theme (vendor layout,
|
|
366
|
+
* theme preview, CLI dev preview) renders the theme's English with zero
|
|
367
|
+
* host cooperation — a host that passes nothing still shows English,
|
|
368
|
+
* never empty labels. ENGLISH ONLY: non-English packs stay host-loaded
|
|
369
|
+
* per locale and are never bundled into the theme. Optional during the
|
|
370
|
+
* migration — a manifest without it behaves exactly as before (kit core
|
|
371
|
+
* English, then `''` for unknown keys).
|
|
372
|
+
*/
|
|
373
|
+
strings?: ThemeStringsDictionary;
|
|
353
374
|
}
|
|
354
375
|
|
|
355
376
|
/**
|