@usequeek/theme-kit 0.1.17 → 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,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
+ });