@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,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook-placement rule (checkout / auth / blog slice) — S2 enforcement.
|
|
3
|
+
*
|
|
4
|
+
* Rule: every `useThemeStrings()` call must sit at the top level of a
|
|
5
|
+
* PascalCase component or a `use*` hook: no enclosing if / ternary / loop /
|
|
6
|
+
* try / switch / && / ||, no early return before it, and never nested inside
|
|
7
|
+
* another function (e.g. a `.map` callback). Violations break the Rules of
|
|
8
|
+
* Hooks and silently untranslate strings, so this test FAILS when broken.
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
11
|
+
import { join, dirname } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { describe, expect, it } from 'vitest';
|
|
14
|
+
import ts from 'typescript';
|
|
15
|
+
|
|
16
|
+
const kitRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
17
|
+
|
|
18
|
+
// Files this slice migrated: each must call useThemeStrings() exactly once.
|
|
19
|
+
const EXPECTED_FILES = [
|
|
20
|
+
'components/checkout/checkout-contact.tsx',
|
|
21
|
+
'components/checkout/default-checkout-shell.tsx',
|
|
22
|
+
'components/checkout/policy-links.tsx',
|
|
23
|
+
'components/auth/auth-email-login-step.tsx',
|
|
24
|
+
'components/auth/auth-email-otp-step.tsx',
|
|
25
|
+
'components/auth/auth-email-otp-verify-step.tsx',
|
|
26
|
+
'components/auth/auth-email-register-step.tsx',
|
|
27
|
+
'components/auth/auth-signup-step.tsx',
|
|
28
|
+
'components/blog/category-filter.tsx',
|
|
29
|
+
'components/blog/pagination.tsx',
|
|
30
|
+
'components/blog/post-meta.tsx',
|
|
31
|
+
'components/blog/related-posts.tsx',
|
|
32
|
+
'components/blog/share-buttons.tsx',
|
|
33
|
+
'hooks/use-auth-flow.tsx',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
function listTsx(dir: string): string[] {
|
|
37
|
+
const out: string[] = [];
|
|
38
|
+
for (const entry of readdirSync(dir)) {
|
|
39
|
+
const full = join(dir, entry);
|
|
40
|
+
if (statSync(full).isDirectory()) out.push(...listTsx(full));
|
|
41
|
+
else if (entry.endsWith('.tsx')) out.push(full);
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const NAME_RE = /^[A-Z][A-Za-z0-9]*$|^use[A-Z]/;
|
|
47
|
+
|
|
48
|
+
interface CallSite {
|
|
49
|
+
file: string;
|
|
50
|
+
line: number;
|
|
51
|
+
problems: string[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function enclosingName(fn: ts.Node): string | null {
|
|
55
|
+
if (ts.isFunctionDeclaration(fn)) return fn.name?.text ?? null;
|
|
56
|
+
if (ts.isMethodDeclaration(fn)) return ts.isIdentifier(fn.name) ? fn.name.text : null;
|
|
57
|
+
if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && ts.isVariableDeclaration(fn.parent)) {
|
|
58
|
+
return fn.parent.name.getText();
|
|
59
|
+
}
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isTopLevelFunction(fn: ts.Node): boolean {
|
|
64
|
+
if (ts.isFunctionDeclaration(fn)) return ts.isSourceFile(fn.parent);
|
|
65
|
+
if (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) {
|
|
66
|
+
return (
|
|
67
|
+
ts.isVariableDeclaration(fn.parent) &&
|
|
68
|
+
ts.isVariableDeclarationList(fn.parent.parent) &&
|
|
69
|
+
ts.isVariableStatement(fn.parent.parent.parent) &&
|
|
70
|
+
ts.isSourceFile(fn.parent.parent.parent.parent)
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function checkFile(rel: string): CallSite[] {
|
|
77
|
+
const src = readFileSync(join(kitRoot, rel), 'utf8');
|
|
78
|
+
const sf = ts.createSourceFile(rel, src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
79
|
+
const sites: CallSite[] = [];
|
|
80
|
+
const stack: ts.Node[] = [];
|
|
81
|
+
|
|
82
|
+
function pushFn(node: ts.Node): void {
|
|
83
|
+
stack.push(node);
|
|
84
|
+
ts.forEachChild(node, visit);
|
|
85
|
+
stack.pop();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function visit(node: ts.Node): void {
|
|
89
|
+
if (
|
|
90
|
+
ts.isFunctionDeclaration(node) ||
|
|
91
|
+
ts.isFunctionExpression(node) ||
|
|
92
|
+
ts.isArrowFunction(node) ||
|
|
93
|
+
ts.isMethodDeclaration(node)
|
|
94
|
+
) {
|
|
95
|
+
pushFn(node);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (
|
|
99
|
+
ts.isCallExpression(node) &&
|
|
100
|
+
ts.isIdentifier(node.expression) &&
|
|
101
|
+
node.expression.text === 'useThemeStrings'
|
|
102
|
+
) {
|
|
103
|
+
const problems: string[] = [];
|
|
104
|
+
const enclosing = stack.length > 0 ? stack[stack.length - 1] : null;
|
|
105
|
+
if (!enclosing || stack.length !== 1) {
|
|
106
|
+
problems.push('not directly inside one top-level function (nested callback?)');
|
|
107
|
+
} else {
|
|
108
|
+
const name = enclosingName(enclosing);
|
|
109
|
+
if (!name || !NAME_RE.test(name)) problems.push(`enclosing function '${name ?? '<anonymous>'}' is not a PascalCase component or use* hook`);
|
|
110
|
+
if (!isTopLevelFunction(enclosing)) problems.push('enclosing function is not a module top-level declaration');
|
|
111
|
+
let p: ts.Node = node.parent;
|
|
112
|
+
while (p && p !== enclosing) {
|
|
113
|
+
if (
|
|
114
|
+
ts.isIfStatement(p) ||
|
|
115
|
+
ts.isConditionalExpression(p) ||
|
|
116
|
+
ts.isForStatement(p) ||
|
|
117
|
+
ts.isForInStatement(p) ||
|
|
118
|
+
ts.isForOfStatement(p) ||
|
|
119
|
+
ts.isWhileStatement(p) ||
|
|
120
|
+
ts.isDoStatement(p) ||
|
|
121
|
+
ts.isTryStatement(p) ||
|
|
122
|
+
ts.isCatchClause(p) ||
|
|
123
|
+
ts.isSwitchStatement(p) ||
|
|
124
|
+
ts.isCaseClause(p) ||
|
|
125
|
+
ts.isDefaultClause(p) ||
|
|
126
|
+
(ts.isBinaryExpression(p) &&
|
|
127
|
+
(p.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken ||
|
|
128
|
+
p.operatorToken.kind === ts.SyntaxKind.BarBarToken))
|
|
129
|
+
) {
|
|
130
|
+
problems.push(`conditionally enclosed by ${ts.SyntaxKind[p.kind]}`);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
p = p.parent;
|
|
134
|
+
}
|
|
135
|
+
const body = (enclosing as ts.FunctionLikeDeclaration).body;
|
|
136
|
+
if (body && ts.isBlock(body)) {
|
|
137
|
+
let host: ts.Node = node;
|
|
138
|
+
while (host.parent && host.parent !== body) host = host.parent;
|
|
139
|
+
for (const stmt of body.statements) {
|
|
140
|
+
if (stmt === host) break;
|
|
141
|
+
if (ts.isReturnStatement(stmt)) {
|
|
142
|
+
problems.push('an early return precedes the hook call');
|
|
143
|
+
break;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
|
|
149
|
+
sites.push({ file: rel, line: line + 1, problems });
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
ts.forEachChild(node, visit);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
visit(sf);
|
|
156
|
+
return sites;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const scopeFiles = listTsx(join(kitRoot, 'components'))
|
|
160
|
+
.concat(listTsx(join(kitRoot, 'hooks')))
|
|
161
|
+
.map((f) => f.slice(kitRoot.length + 1));
|
|
162
|
+
|
|
163
|
+
describe('useThemeStrings() placement (Rules of Hooks)', () => {
|
|
164
|
+
it('every migrated file calls useThemeStrings() exactly once', () => {
|
|
165
|
+
for (const rel of EXPECTED_FILES) {
|
|
166
|
+
expect(checkFile(rel).length, `${rel}: expected exactly one call`).toBe(1);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('every call in components/ + hooks/ is top-level in a component or hook', () => {
|
|
171
|
+
const bad: string[] = [];
|
|
172
|
+
for (const rel of scopeFiles) {
|
|
173
|
+
for (const site of checkFile(rel)) {
|
|
174
|
+
for (const problem of site.problems) bad.push(`${site.file}:${site.line}: ${problem}`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
expect(bad, bad.join('\n')).toEqual([]);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
2
|
+
import type { JSX, ReactNode } from 'react';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import enDefault from '../locales/en.default.json';
|
|
5
|
+
import { StorefrontProvider, useThemeStrings } from '../provider';
|
|
6
|
+
import { defaultThemeStrings, t, type ThemeStringsDictionary } from '../strings/theme-strings';
|
|
7
|
+
import {
|
|
8
|
+
assertNoEnglishLeak,
|
|
9
|
+
findEnglishLeaks,
|
|
10
|
+
isPseudoLocalized,
|
|
11
|
+
pseudoLocalize,
|
|
12
|
+
pseudoLocalizeText,
|
|
13
|
+
} from '../strings/pseudo-locale';
|
|
14
|
+
|
|
15
|
+
const EN = enDefault as unknown as ThemeStringsDictionary;
|
|
16
|
+
|
|
17
|
+
function render(children: ReactNode, strings: ThemeStringsDictionary | null): string {
|
|
18
|
+
return renderToStaticMarkup(
|
|
19
|
+
<StorefrontProvider
|
|
20
|
+
vendor={{ id: 'v1', slug: 'kili-foods', name: 'Kili Foods' } as never}
|
|
21
|
+
config={{} as never}
|
|
22
|
+
menus={[]}
|
|
23
|
+
{...(strings ? { strings } : {})}
|
|
24
|
+
>
|
|
25
|
+
{children}
|
|
26
|
+
</StorefrontProvider>,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function Title(): JSX.Element {
|
|
31
|
+
const t = useThemeStrings();
|
|
32
|
+
return <h1>{t('cart.title')}</h1>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('pseudoLocalize (pure dictionary transform)', () => {
|
|
36
|
+
it('accent-maps, expands ~30% and wraps in markers', () => {
|
|
37
|
+
expect(pseudoLocalizeText('Your cart')).toBe('[!! Ÿòùŕ çàŕţ~~~ !!]');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('preserves {placeholders} byte-for-byte and still interpolates', () => {
|
|
41
|
+
const pseudo = pseudoLocalizeText('{count} items');
|
|
42
|
+
expect(pseudo).toBe('[!! {count} ìţèṁš~~~~ !!]');
|
|
43
|
+
expect(pseudo).toContain('{count}');
|
|
44
|
+
expect(t([{ v: pseudo }], 'v', { count: 3 }, 'en')).toBe('[!! 3 ìţèṁš~~~~ !!]');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('preserves `{{` / `}}` escapes (structure survives, literal is pseudo)', () => {
|
|
48
|
+
const pseudo = pseudoLocalizeText('a {{b}} c');
|
|
49
|
+
expect(pseudo).toContain('{{');
|
|
50
|
+
expect(pseudo).toContain('}}');
|
|
51
|
+
// Same interpolation behaviour as the source: escapes still escape.
|
|
52
|
+
expect(t([{ v: pseudo }], 'v', {}, 'en')).toBe('[!! à {ƀ} ç~~~ !!]');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('keeps plural-map shape (one key, every form pseudo)', () => {
|
|
56
|
+
const dict: ThemeStringsDictionary = {
|
|
57
|
+
items: { count: { one: '{count} item', other: '{count} items' } },
|
|
58
|
+
};
|
|
59
|
+
const pseudo = pseudoLocalize(dict);
|
|
60
|
+
expect(Object.keys((pseudo['items'] as Record<string, unknown>)['count'] as object)).toEqual([
|
|
61
|
+
'one',
|
|
62
|
+
'other',
|
|
63
|
+
]);
|
|
64
|
+
const forms = (pseudo['items'] as { count: { one: string; other: string } }).count;
|
|
65
|
+
expect(forms.one).toContain('{count}');
|
|
66
|
+
expect(isPseudoLocalized(forms.one)).toBe(true);
|
|
67
|
+
expect(isPseudoLocalized(forms.other)).toBe(true);
|
|
68
|
+
// t() still selects forms from the pseudo dictionary.
|
|
69
|
+
expect(t([pseudo], 'items.count', { count: 1 }, 'en')).toContain('1');
|
|
70
|
+
expect(t([pseudo], 'items.count', { count: 5 }, 'en')).toContain('5');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('recurses nested objects and never touches keys (incl. flat dotted keys)', () => {
|
|
74
|
+
const dict = {
|
|
75
|
+
cart: { title: 'Your cart', empty: { message: 'Your cart is empty.' } },
|
|
76
|
+
'flat.key': 'Flat value',
|
|
77
|
+
} as unknown as ThemeStringsDictionary;
|
|
78
|
+
const pseudo = pseudoLocalize(dict);
|
|
79
|
+
expect(Object.keys(pseudo).sort()).toEqual(['cart', 'flat.key']);
|
|
80
|
+
expect(Object.keys(pseudo['cart'] as object).sort()).toEqual(['empty', 'title']);
|
|
81
|
+
expect(t([pseudo], 'flat.key')).toBe(pseudoLocalizeText('Flat value'));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('leaves empty values empty (an intentional empty renders no node)', () => {
|
|
85
|
+
expect(pseudoLocalizeText('')).toBe('');
|
|
86
|
+
const pseudo = pseudoLocalize({ hidden: { label: '' } });
|
|
87
|
+
expect((pseudo['hidden'] as { label: string }).label).toBe('');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('expands very long values by ~30% (1000-char budget value)', () => {
|
|
91
|
+
const long = 'a'.repeat(1000);
|
|
92
|
+
const pseudo = pseudoLocalizeText(long);
|
|
93
|
+
const core = pseudo.slice('[!! '.length, -' !!]'.length);
|
|
94
|
+
expect(core.length).toBe(1000 + Math.round(1000 * 0.3));
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('leaves unicode (non-Latin, emoji) intact, wrapped', () => {
|
|
98
|
+
const pseudo = pseudoLocalizeText('こんにちは世界 🛒');
|
|
99
|
+
expect(pseudo).toContain('こんにちは世界');
|
|
100
|
+
expect(pseudo).toContain('🛒');
|
|
101
|
+
expect(isPseudoLocalized(pseudo)).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('rtl option wraps the core in U+202B … U+202C', () => {
|
|
105
|
+
const pseudo = pseudoLocalizeText('Your cart', { rtl: true });
|
|
106
|
+
expect(pseudo.includes('\u202B')).toBe(true);
|
|
107
|
+
expect(pseudo.includes('\u202C')).toBe(true);
|
|
108
|
+
expect(isPseudoLocalized(pseudo)).toBe(true);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('custom markers are honoured by the scan', () => {
|
|
112
|
+
const pseudo = pseudoLocalizeText('Your cart', { prefix: '‹', suffix: '›' });
|
|
113
|
+
expect(pseudo.startsWith('‹')).toBe(true);
|
|
114
|
+
expect(isPseudoLocalized(pseudo)).toBe(false);
|
|
115
|
+
expect(isPseudoLocalized(pseudo, { prefix: '‹', suffix: '›' })).toBe(true);
|
|
116
|
+
expect(findEnglishLeaks(`<h1>${pseudo}</h1>`, { pseudo: { prefix: '‹', suffix: '›' } })).toEqual(
|
|
117
|
+
[],
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('English leak scan (reusable theme-test primitive)', () => {
|
|
123
|
+
it('a fully pseudo render is clean', () => {
|
|
124
|
+
const html = render(<Title />, pseudoLocalize(EN));
|
|
125
|
+
expect(html).not.toContain('Your cart');
|
|
126
|
+
expect(findEnglishLeaks(html)).toEqual([]);
|
|
127
|
+
expect(() => assertNoEnglishLeak(html)).not.toThrow();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('a hard-coded English string is reported (the control that proves the scan fails)', () => {
|
|
131
|
+
function Leaky(): JSX.Element {
|
|
132
|
+
const t = useThemeStrings();
|
|
133
|
+
return (
|
|
134
|
+
<main>
|
|
135
|
+
<h1>{t('cart.title')}</h1>
|
|
136
|
+
<p>Hard-coded checkout copy</p>
|
|
137
|
+
</main>
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
const html = render(<Leaky />, pseudoLocalize(EN));
|
|
141
|
+
expect(findEnglishLeaks(html)).toEqual(['Hard-coded checkout copy']);
|
|
142
|
+
expect(() => assertNoEnglishLeak(html)).toThrow(/Hard-coded checkout copy/);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('flags hard-coded user-visible attributes (aria-label, placeholder)', () => {
|
|
146
|
+
const html = `<button aria-label="Close dialog">×</button><input placeholder="Search products" />`;
|
|
147
|
+
expect(findEnglishLeaks(html)).toEqual(['Close dialog', 'Search products']);
|
|
148
|
+
expect(findEnglishLeaks(html, { includeAttributes: false })).toEqual([]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('ignores numbers, symbols and pseudo-wrapped attributes', () => {
|
|
152
|
+
const html = render(<Title />, pseudoLocalize(EN));
|
|
153
|
+
expect(findEnglishLeaks(`<span>₦5,000</span><span>2 / 5</span>${html}`)).toEqual([]);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('allowlists brand names and codes', () => {
|
|
157
|
+
const html = `<footer><span>${pseudoLocalizeText('Powered by')}</span><span>Queek</span></footer>`;
|
|
158
|
+
expect(findEnglishLeaks(html)).toEqual(['Queek']);
|
|
159
|
+
expect(findEnglishLeaks(html, { allowlist: ['Queek'] })).toEqual([]);
|
|
160
|
+
expect(findEnglishLeaks(html, { allowlist: [/^Q/] })).toEqual([]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('pseudo of the real English default keeps every key (renamed-key drift fails here)', () => {
|
|
164
|
+
const pseudo = pseudoLocalize(EN);
|
|
165
|
+
expect(Object.keys(pseudo).sort()).toEqual(Object.keys(EN).sort());
|
|
166
|
+
// Spot values still resolve through t() — a changed English default that
|
|
167
|
+
// was not rebuilt into pseudo expectations fails loudly, never silently.
|
|
168
|
+
expect(t([pseudo], 'cart.title')).toBe(pseudoLocalizeText('Your cart'));
|
|
169
|
+
expect(t([defaultThemeStrings], 'cart.title')).toBe('Your cart');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
2
|
+
import type { JSX } from 'react';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import enDefault from '../locales/en.default.json';
|
|
5
|
+
import {
|
|
6
|
+
createThemeT,
|
|
7
|
+
defaultThemeStrings,
|
|
8
|
+
t,
|
|
9
|
+
type ThemeStringKey,
|
|
10
|
+
type ThemeStringsDictionary,
|
|
11
|
+
} from '../strings/theme-strings';
|
|
12
|
+
import { StorefrontProvider, useThemeStrings } from '../provider';
|
|
13
|
+
|
|
14
|
+
/** Small theme-shaped dictionary exercising nesting, plurals and flat keys. */
|
|
15
|
+
const THEME_EN = {
|
|
16
|
+
cart: {
|
|
17
|
+
title: 'Your cart',
|
|
18
|
+
empty: { message: 'Your cart is empty.' },
|
|
19
|
+
},
|
|
20
|
+
items: {
|
|
21
|
+
count: { one: '{count} item', other: '{count} items' },
|
|
22
|
+
},
|
|
23
|
+
'flat.key': 'Flat value',
|
|
24
|
+
greet: 'Hello {name}!',
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
describe('typed keys: createThemeT (additive, opt-in)', () => {
|
|
28
|
+
it('delegates to the wrapped t (values, vars, plurals identical)', () => {
|
|
29
|
+
const dict = THEME_EN as unknown as ThemeStringsDictionary;
|
|
30
|
+
const tt = createThemeT<typeof THEME_EN>((key, vars, locale) => t([dict], key, vars, locale));
|
|
31
|
+
|
|
32
|
+
expect(tt('cart.title')).toBe('Your cart');
|
|
33
|
+
expect(tt('greet', { name: 'Ada' })).toBe('Hello Ada!');
|
|
34
|
+
expect(tt('items.count', { count: 1 })).toBe('1 item');
|
|
35
|
+
expect(tt('items.count', { count: 5 })).toBe('5 items');
|
|
36
|
+
expect(tt('flat.key')).toBe('Flat value');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('wraps the client useThemeStrings() hook result', () => {
|
|
40
|
+
function Title(): JSX.Element {
|
|
41
|
+
const tUntyped = useThemeStrings();
|
|
42
|
+
const tt = createThemeT<typeof THEME_EN>(tUntyped);
|
|
43
|
+
return <h1>{tt('cart.title')}</h1>;
|
|
44
|
+
}
|
|
45
|
+
const html = renderToStaticMarkup(
|
|
46
|
+
<StorefrontProvider
|
|
47
|
+
vendor={{ id: 'v1', slug: 's', name: 'S' } as never}
|
|
48
|
+
config={{} as never}
|
|
49
|
+
menus={[]}
|
|
50
|
+
strings={THEME_EN as unknown as ThemeStringsDictionary}
|
|
51
|
+
>
|
|
52
|
+
<Title />
|
|
53
|
+
</StorefrontProvider>,
|
|
54
|
+
);
|
|
55
|
+
expect(html).toContain('<h1>Your cart</h1>');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('accepts every real en.default.json key (valid keys pass)', () => {
|
|
59
|
+
const tt = createThemeT<typeof enDefault>((key, vars, locale) =>
|
|
60
|
+
t([defaultThemeStrings], key, vars, locale),
|
|
61
|
+
);
|
|
62
|
+
// A sample across nesting depths — each must compile AND render English.
|
|
63
|
+
expect(tt('cart.title')).toBe('Your cart');
|
|
64
|
+
expect(tt('cart.empty.message')).toBe('Your cart is empty.');
|
|
65
|
+
expect(tt('checkout.contact.label')).toBe('Contact');
|
|
66
|
+
expect(tt('auth.login.title')).toBe('Sign in');
|
|
67
|
+
expect(tt('blog.share.copy')).toBe('Copy link');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('existing untyped t(key) callers keep compiling (key: string)', () => {
|
|
71
|
+
const anyKey: string = `cart.title`;
|
|
72
|
+
// A dynamic string — not a literal — still flows through the untyped fn.
|
|
73
|
+
expect(t([defaultThemeStrings], anyKey)).toBe('Your cart');
|
|
74
|
+
// A broad dictionary collapses the typed helper back to `string` (untyped).
|
|
75
|
+
const loose = createThemeT<ThemeStringsDictionary>((key, vars, locale) =>
|
|
76
|
+
t([defaultThemeStrings], key, vars, locale),
|
|
77
|
+
);
|
|
78
|
+
expect(loose(anyKey)).toBe('Your cart');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('drifts with the dictionary: the union is exactly the flattened keys', () => {
|
|
82
|
+
// Compile-time proof (positive direction; the negative direction — typos
|
|
83
|
+
// fail — lives in tests/theme-typed-keys.fixture.ts via @ts-expect-error
|
|
84
|
+
// so `npm run typecheck` stays green).
|
|
85
|
+
const keys: Array<ThemeStringKey<typeof THEME_EN>> = [
|
|
86
|
+
'cart.title',
|
|
87
|
+
'cart.empty.message',
|
|
88
|
+
'items.count',
|
|
89
|
+
'flat.key',
|
|
90
|
+
'greet',
|
|
91
|
+
];
|
|
92
|
+
expect(keys).toHaveLength(5);
|
|
93
|
+
// Renaming/removing a key above breaks this file AND the fixture: a
|
|
94
|
+
// renamed English key is a contract change, not a silent drift.
|
|
95
|
+
expect(keys).toContain('cart.title');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -239,9 +239,15 @@ describe('default English dictionary budget (backend varchar(64))', () => {
|
|
|
239
239
|
const pairs: Array<[string, string]> = [];
|
|
240
240
|
flatten(enDefault, '', pairs);
|
|
241
241
|
|
|
242
|
-
it('ships the ~
|
|
242
|
+
it('ships the ~106-string core dictionary (intentional growth, still budgeted)', () => {
|
|
243
|
+
// Dictionary budget: the checkout/auth/blog shopper-chrome slice added 31
|
|
244
|
+
// keys and removed 1 orphaned pre-existing key (auth.code.sent, 0 usages,
|
|
245
|
+
// superseded by auth.code.lead): 76 -> 106, commit 5cb67dd + orphan removal.
|
|
246
|
+
// The ceiling stays tight (+9 headroom): any further growth must bump it
|
|
247
|
+
// here deliberately with its own justification, which keeps guarding
|
|
248
|
+
// against uncontrolled growth (backend varchar(64) per-key budget below).
|
|
243
249
|
expect(pairs.length).toBeGreaterThanOrEqual(70);
|
|
244
|
-
expect(pairs.length).
|
|
250
|
+
expect(pairs.length).toBeLessThanOrEqual(115);
|
|
245
251
|
});
|
|
246
252
|
|
|
247
253
|
it('every key fits the dotted-lowercase grammar and the 40-char budget', () => {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type-level test (tsd-style via tsc): a typo key FAILS and a valid key
|
|
3
|
+
* PASSES. This file must stay green under `npm run typecheck` — every
|
|
4
|
+
* `@ts-expect-error` below is consumed only if the marked line is a type
|
|
5
|
+
* error, so deleting the error (or breaking the helper) fails the gate.
|
|
6
|
+
*
|
|
7
|
+
* NOTE: intentionally self-contained (no `locales/*.json` import) so the
|
|
8
|
+
* per-locale bundle gate keeps counting exactly one static locale importer
|
|
9
|
+
* (`strings/theme-strings.ts`).
|
|
10
|
+
*/
|
|
11
|
+
import {
|
|
12
|
+
createThemeT,
|
|
13
|
+
t,
|
|
14
|
+
type ThemeStringKey,
|
|
15
|
+
type ThemeStringsDictionary,
|
|
16
|
+
} from '../strings/theme-strings';
|
|
17
|
+
|
|
18
|
+
const strings = {
|
|
19
|
+
cart: {
|
|
20
|
+
title: 'Your cart',
|
|
21
|
+
empty: { message: 'Your cart is empty.' },
|
|
22
|
+
},
|
|
23
|
+
items: {
|
|
24
|
+
count: { one: '{count} item', other: '{count} items' },
|
|
25
|
+
},
|
|
26
|
+
'flat.key': 'Flat value',
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
type K = ThemeStringKey<typeof strings>;
|
|
30
|
+
|
|
31
|
+
// The union is EXACTLY the flattened keys (mutual assignability proves it:
|
|
32
|
+
// adding or dropping a key on either side breaks this file).
|
|
33
|
+
type Expected = 'cart.title' | 'cart.empty.message' | 'items.count' | 'flat.key';
|
|
34
|
+
const exact1: Expected = null as unknown as K;
|
|
35
|
+
const exact2: K = null as unknown as Expected;
|
|
36
|
+
void exact1;
|
|
37
|
+
void exact2;
|
|
38
|
+
|
|
39
|
+
// Valid keys pass …
|
|
40
|
+
const okTitle: K = 'cart.title';
|
|
41
|
+
const okNested: K = 'cart.empty.message';
|
|
42
|
+
const okPlural: K = 'items.count';
|
|
43
|
+
const okFlat: K = 'flat.key';
|
|
44
|
+
void okTitle;
|
|
45
|
+
void okNested;
|
|
46
|
+
void okPlural;
|
|
47
|
+
void okFlat;
|
|
48
|
+
|
|
49
|
+
// … and plural sub-forms are NOT keys (the map is one key) …
|
|
50
|
+
// @ts-expect-error - 'items.count.one' is a form, not a key
|
|
51
|
+
const badForm: K = 'items.count.one';
|
|
52
|
+
void badForm;
|
|
53
|
+
|
|
54
|
+
// … a typo key fails …
|
|
55
|
+
// @ts-expect-error - typo: 'cart.titl' is not a key
|
|
56
|
+
const badTypo: K = 'cart.titl';
|
|
57
|
+
void badTypo;
|
|
58
|
+
|
|
59
|
+
// … and an unknown key fails.
|
|
60
|
+
// @ts-expect-error - 'no.such.key' is not a key
|
|
61
|
+
const badUnknown: K = 'no.such.key';
|
|
62
|
+
void badUnknown;
|
|
63
|
+
|
|
64
|
+
// The typed binding enforces the same at the call site.
|
|
65
|
+
const dict = strings as unknown as ThemeStringsDictionary;
|
|
66
|
+
const tt = createThemeT<typeof strings>((key, vars, locale) => t([dict], key, vars, locale));
|
|
67
|
+
tt('cart.title');
|
|
68
|
+
tt('items.count', { count: 2 });
|
|
69
|
+
// @ts-expect-error - typo key fails through createThemeT too
|
|
70
|
+
tt('cart.titl');
|
|
71
|
+
|
|
72
|
+
// A broad dictionary stays untyped (existing dynamic-key callers compile).
|
|
73
|
+
declare const anyKey: string;
|
|
74
|
+
const loose = createThemeT<ThemeStringsDictionary>((key, vars, locale) => t([dict], key, vars, locale));
|
|
75
|
+
loose(anyKey);
|
|
76
|
+
t([dict], anyKey);
|
package/utils/format.ts
CHANGED
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
* shows decimals only when the amount genuinely has them — ₦5,000 stays
|
|
16
16
|
* ₦5,000, ₦0.28 now correctly shows as ₦0.28.
|
|
17
17
|
*/
|
|
18
|
+
import {
|
|
19
|
+
ENGLISH_DISPLAY_LOCALE,
|
|
20
|
+
resolveIntlLocale,
|
|
21
|
+
resolveSupportedLocale,
|
|
22
|
+
} from './locale';
|
|
23
|
+
|
|
18
24
|
/**
|
|
19
25
|
* The locale and zone every date/number the kit DISPLAYS is formatted in.
|
|
20
26
|
* Fixed on purpose: storefront pages render on the server first, and a runtime
|
|
@@ -22,11 +28,33 @@
|
|
|
22
28
|
* browser (Africa/Lagos, en-NG/en-GB) — "Feb 14, 2026" vs "14 Feb 2026". React
|
|
23
29
|
* then fails hydration and re-renders the whole page on the client. Queek's
|
|
24
30
|
* stores and shoppers are Nigerian, so that is the one answer both sides give.
|
|
31
|
+
*
|
|
32
|
+
* Aliases `ENGLISH_DISPLAY_LOCALE` (`utils/locale.ts`) — the value lives in
|
|
33
|
+
* exactly one place. `resolveIntlLocale` maps every English storefront code
|
|
34
|
+
* here, so English output is byte-identical with or without a locale.
|
|
25
35
|
*/
|
|
26
|
-
export const DISPLAY_LOCALE =
|
|
36
|
+
export const DISPLAY_LOCALE: string = ENGLISH_DISPLAY_LOCALE;
|
|
27
37
|
export const DISPLAY_TIME_ZONE = 'Africa/Lagos';
|
|
28
38
|
|
|
29
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Pick the Intl tag for one formatting call: the storefront locale through
|
|
41
|
+
* the canonical mapping, or English when the runtime's ICU lacks the locale.
|
|
42
|
+
* Never throws.
|
|
43
|
+
*/
|
|
44
|
+
function displayTagFor(
|
|
45
|
+
locale: string | null | undefined,
|
|
46
|
+
supportedLocalesOf: (locales: string[]) => readonly string[],
|
|
47
|
+
): string {
|
|
48
|
+
return resolveSupportedLocale(resolveIntlLocale(locale), supportedLocalesOf);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A date for display (default "14 Feb 2026"), identical on server and browser.
|
|
53
|
+
* '' if unparseable. The locale parameter is routed through the canonical
|
|
54
|
+
* mapping (founder 2/10/26: English is British/Nigerian order, never US
|
|
55
|
+
* order), so even an explicit 'en'/'en-US' renders "15 February 2026" — no
|
|
56
|
+
* exported formatter lets an 'en*' tag reach Intl unmapped.
|
|
57
|
+
*/
|
|
30
58
|
export function formatDisplayDate(
|
|
31
59
|
value: string | number | Date,
|
|
32
60
|
options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric' },
|
|
@@ -34,12 +62,66 @@ export function formatDisplayDate(
|
|
|
34
62
|
): string {
|
|
35
63
|
const date = value instanceof Date ? value : new Date(value);
|
|
36
64
|
if (Number.isNaN(date.getTime())) return '';
|
|
37
|
-
return date.toLocaleDateString(locale, {
|
|
65
|
+
return date.toLocaleDateString(resolveIntlLocale(locale), {
|
|
66
|
+
...options,
|
|
67
|
+
timeZone: DISPLAY_TIME_ZONE,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A date for display in the SHOPPER's locale: `formatDisplayDate` with the
|
|
73
|
+
* storefront locale (from `useStorefrontLocale()`) already resolved through
|
|
74
|
+
* the canonical mapping. English renders exactly as `formatDisplayDate`
|
|
75
|
+
* ("14 Feb 2026"); `fr` renders "14 févr. 2026", and so on. Time zone and
|
|
76
|
+
* invalid-date handling are `formatDisplayDate`'s. Never throws.
|
|
77
|
+
*/
|
|
78
|
+
export function formatShopperDate(
|
|
79
|
+
value: string | number | Date,
|
|
80
|
+
locale: string | null | undefined,
|
|
81
|
+
options: Intl.DateTimeFormatOptions = { day: 'numeric', month: 'short', year: 'numeric' },
|
|
82
|
+
): string {
|
|
83
|
+
return formatDisplayDate(
|
|
84
|
+
value,
|
|
85
|
+
options,
|
|
86
|
+
displayTagFor(locale, (l) => Intl.DateTimeFormat.supportedLocalesOf(l)),
|
|
87
|
+
);
|
|
38
88
|
}
|
|
39
89
|
|
|
40
90
|
/** A count for display ("1,234"), identical on server and browser. */
|
|
41
|
-
export function formatCount(value: number): string {
|
|
42
|
-
|
|
91
|
+
export function formatCount(value: number, locale?: string | null): string {
|
|
92
|
+
const tag = displayTagFor(locale, (l) => Intl.NumberFormat.supportedLocalesOf(l));
|
|
93
|
+
try {
|
|
94
|
+
return value.toLocaleString(tag);
|
|
95
|
+
} catch {
|
|
96
|
+
return value.toLocaleString(DISPLAY_LOCALE);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Relative time for display ("yesterday", "il y a 3 jours") in the shopper's
|
|
102
|
+
* locale — `numeric: 'auto'` so whole days read as words where the language
|
|
103
|
+
* has them. Falls back to English on a missing ICU locale or a bad unit;
|
|
104
|
+
* never throws.
|
|
105
|
+
*/
|
|
106
|
+
export function formatRelativeTime(
|
|
107
|
+
value: number,
|
|
108
|
+
unit: Intl.RelativeTimeFormatUnit = 'day',
|
|
109
|
+
locale?: string | null,
|
|
110
|
+
options?: Intl.RelativeTimeFormatOptions,
|
|
111
|
+
): string {
|
|
112
|
+
const tag = displayTagFor(locale, (l) => Intl.RelativeTimeFormat.supportedLocalesOf(l));
|
|
113
|
+
try {
|
|
114
|
+
return new Intl.RelativeTimeFormat(tag, { numeric: 'auto', ...options }).format(value, unit);
|
|
115
|
+
} catch {
|
|
116
|
+
try {
|
|
117
|
+
return new Intl.RelativeTimeFormat(DISPLAY_LOCALE, { numeric: 'auto', ...options }).format(
|
|
118
|
+
value,
|
|
119
|
+
unit,
|
|
120
|
+
);
|
|
121
|
+
} catch {
|
|
122
|
+
return `${value} ${unit}`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
43
125
|
}
|
|
44
126
|
|
|
45
127
|
/**
|
|
@@ -64,16 +146,22 @@ export function normalizeCurrencyCode(value: unknown, fallback: string = PLATFOR
|
|
|
64
146
|
return /^[A-Z]{3}$/.test(cleanFallback) ? cleanFallback : PLATFORM_DEFAULT_CURRENCY;
|
|
65
147
|
}
|
|
66
148
|
|
|
67
|
-
export function formatMoney(
|
|
149
|
+
export function formatMoney(
|
|
150
|
+
amount: number,
|
|
151
|
+
currency = PLATFORM_DEFAULT_CURRENCY,
|
|
152
|
+
locale?: string | null,
|
|
153
|
+
): string {
|
|
154
|
+
const code = normalizeCurrencyCode(currency);
|
|
155
|
+
const tag = displayTagFor(locale, (l) => Intl.NumberFormat.supportedLocalesOf(l));
|
|
68
156
|
try {
|
|
69
|
-
return new Intl.NumberFormat(
|
|
157
|
+
return new Intl.NumberFormat(tag, {
|
|
70
158
|
style: 'currency',
|
|
71
|
-
currency,
|
|
159
|
+
currency: code,
|
|
72
160
|
minimumFractionDigits: 0,
|
|
73
161
|
maximumFractionDigits: 2,
|
|
74
162
|
}).format(amount);
|
|
75
163
|
} catch {
|
|
76
|
-
return `${
|
|
164
|
+
return `${code} ${amount.toLocaleString(DISPLAY_LOCALE)}`;
|
|
77
165
|
}
|
|
78
166
|
}
|
|
79
167
|
|