@usequeek/theme-kit 0.1.19 → 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 +121 -1
- 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/components/powered-by-queek.tsx +12 -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/powered-by-queek.test.tsx +31 -3
- package/tests/powered-by-url.test.ts +49 -0
- 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
- package/utils/powered-by.ts +31 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shopper-chrome theme strings (checkout / auth / blog) — English-safety guard.
|
|
3
|
+
*
|
|
4
|
+
* Stage A: every state below was rendered against the PRE-migration code and
|
|
5
|
+
* stored in `tests/fixtures/shopper-chrome-baseline.json`. Each case asserts
|
|
6
|
+
* `.toBe` equality against that stored OLD markup, so ANY English change
|
|
7
|
+
* (value, markup, attribute, plural) fails here. S4 drift guard + S7 proof.
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync } from 'node:fs';
|
|
10
|
+
import { join, dirname } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
13
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
14
|
+
import type { JSX, ReactNode } from 'react';
|
|
15
|
+
import { StorefrontProvider } from '../provider';
|
|
16
|
+
import { CheckoutContactSection } from '../components/checkout/checkout-contact';
|
|
17
|
+
import { DefaultCheckoutShell } from '../components/checkout/default-checkout-shell';
|
|
18
|
+
import { PolicyLinks } from '../components/checkout/policy-links';
|
|
19
|
+
import { AuthEmailLoginStep } from '../components/auth/auth-email-login-step';
|
|
20
|
+
import { AuthEmailOtpStep } from '../components/auth/auth-email-otp-step';
|
|
21
|
+
import { AuthEmailOtpVerifyStep } from '../components/auth/auth-email-otp-verify-step';
|
|
22
|
+
import { AuthEmailRegisterStep } from '../components/auth/auth-email-register-step';
|
|
23
|
+
import { AuthSignupStep } from '../components/auth/auth-signup-step';
|
|
24
|
+
import { CategoryFilter } from '../components/blog/category-filter';
|
|
25
|
+
import { BlogPagination } from '../components/blog/pagination';
|
|
26
|
+
import { RelatedPosts } from '../components/blog/related-posts';
|
|
27
|
+
import { ShareButtons } from '../components/blog/share-buttons';
|
|
28
|
+
import { PostMeta } from '../components/blog/post-meta';
|
|
29
|
+
import type { Page } from '../types/page';
|
|
30
|
+
import { t, defaultThemeStrings } from '../strings/theme-strings';
|
|
31
|
+
|
|
32
|
+
vi.mock('../hooks/use-auth-flow', async (importOriginal) => {
|
|
33
|
+
const actual = await importOriginal<typeof import('../hooks/use-auth-flow')>();
|
|
34
|
+
return {
|
|
35
|
+
...actual,
|
|
36
|
+
useAuthFlow: () => (globalThis as unknown as { __flow: unknown }).__flow,
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
vi.mock('../hooks/use-auth', () => ({
|
|
41
|
+
useAuth: () => (globalThis as unknown as { __auth: unknown }).__auth,
|
|
42
|
+
}));
|
|
43
|
+
|
|
44
|
+
const G = globalThis as unknown as { __flow: unknown; __auth: unknown };
|
|
45
|
+
|
|
46
|
+
function baseFlow(over: Record<string, unknown> = {}): void {
|
|
47
|
+
G.__flow = {
|
|
48
|
+
step: 'email-otp',
|
|
49
|
+
otp: '',
|
|
50
|
+
firstName: '',
|
|
51
|
+
lastName: '',
|
|
52
|
+
email: '',
|
|
53
|
+
password: '',
|
|
54
|
+
loading: false,
|
|
55
|
+
error: null,
|
|
56
|
+
setOtp: vi.fn(),
|
|
57
|
+
setFirstName: vi.fn(),
|
|
58
|
+
setLastName: vi.fn(),
|
|
59
|
+
setEmail: vi.fn(),
|
|
60
|
+
setPassword: vi.fn(),
|
|
61
|
+
submitEmailRequestOtp: vi.fn(),
|
|
62
|
+
submitEmailVerifyOtp: vi.fn(),
|
|
63
|
+
submitRegister: vi.fn(),
|
|
64
|
+
submitEmailLogin: vi.fn(),
|
|
65
|
+
submitEmailRegister: vi.fn(),
|
|
66
|
+
initiateGoogleAuth: vi.fn(),
|
|
67
|
+
reset: vi.fn(),
|
|
68
|
+
goToEmailOtp: vi.fn(),
|
|
69
|
+
goToEmailLogin: vi.fn(),
|
|
70
|
+
goToEmailRegister: vi.fn(),
|
|
71
|
+
...over,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function signedOut(): void {
|
|
76
|
+
G.__auth = {
|
|
77
|
+
user: null,
|
|
78
|
+
isAuthenticated: false,
|
|
79
|
+
openAuthModal: vi.fn(),
|
|
80
|
+
logout: vi.fn(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function signedIn(over: Record<string, unknown> = {}): void {
|
|
85
|
+
G.__auth = {
|
|
86
|
+
user: {
|
|
87
|
+
id: 'u1',
|
|
88
|
+
name: 'Adaeze Okafor',
|
|
89
|
+
first_name: 'Adaeze',
|
|
90
|
+
last_name: 'Okafor',
|
|
91
|
+
email: 'ada@example.com',
|
|
92
|
+
phone: '',
|
|
93
|
+
avatar: null,
|
|
94
|
+
},
|
|
95
|
+
isAuthenticated: true,
|
|
96
|
+
openAuthModal: vi.fn(),
|
|
97
|
+
logout: vi.fn(),
|
|
98
|
+
...over,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const AVATAR_USER = {
|
|
103
|
+
id: 'u1',
|
|
104
|
+
name: 'Adaeze Okafor',
|
|
105
|
+
first_name: 'Adaeze',
|
|
106
|
+
last_name: 'Okafor',
|
|
107
|
+
email: 'ada@example.com',
|
|
108
|
+
phone: '',
|
|
109
|
+
avatar: 'https://cdn.example.com/a.png',
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function render(children: ReactNode): string {
|
|
113
|
+
return renderToStaticMarkup(
|
|
114
|
+
<StorefrontProvider
|
|
115
|
+
vendor={{ id: 'v1', slug: 'kili-foods', name: 'Kili Foods' } as never}
|
|
116
|
+
config={{} as never}
|
|
117
|
+
menus={[]}
|
|
118
|
+
>
|
|
119
|
+
{children}
|
|
120
|
+
</StorefrontProvider>,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function PICK({ label }: { label: string }): JSX.Element {
|
|
125
|
+
return <span data-pick={label}>{label}</span>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
129
|
+
function shellProps(over: Record<string, unknown> = {}): any {
|
|
130
|
+
return {
|
|
131
|
+
cartItems: <PICK label="items" />,
|
|
132
|
+
deliveryModes: <PICK label="modes" />,
|
|
133
|
+
addressPicker: <PICK label="address" />,
|
|
134
|
+
deliverySourcePicker: null,
|
|
135
|
+
shippingZonePicker: <PICK label="zone" />,
|
|
136
|
+
schedulePicker: <PICK label="schedule" />,
|
|
137
|
+
paymentPicker: <PICK label="pay" />,
|
|
138
|
+
promoInput: <PICK label="promo" />,
|
|
139
|
+
noteInputs: <PICK label="notes" />,
|
|
140
|
+
feesBreakdown: <PICK label="fees" />,
|
|
141
|
+
deliveryMessage: null,
|
|
142
|
+
submitButton: <PICK label="submit" />,
|
|
143
|
+
activeTab: 'summary',
|
|
144
|
+
onTabChange: () => {},
|
|
145
|
+
totalCount: 0,
|
|
146
|
+
subtotal: 0,
|
|
147
|
+
currency: 'NGN',
|
|
148
|
+
mode: 'page',
|
|
149
|
+
policies: [],
|
|
150
|
+
...over,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const POLICIES = [
|
|
155
|
+
{ id: 'p1', slug: 'refund', type: 'page', title: 'Refund policy', content: [], excerpt: null, cover_image_url: null },
|
|
156
|
+
{ id: 'p2', slug: 'shipping', type: 'page', title: 'Shipping policy', content: [], excerpt: null, cover_image_url: null },
|
|
157
|
+
] as unknown as Page[];
|
|
158
|
+
|
|
159
|
+
const CATS = [
|
|
160
|
+
{ slug: 'news', name: 'News', posts_count: 4 },
|
|
161
|
+
{ slug: 'recipes', name: 'Recipes' },
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
function post(over: Record<string, unknown> = {}): Record<string, unknown> {
|
|
165
|
+
return {
|
|
166
|
+
id: 'post1',
|
|
167
|
+
slug: 'hello-world',
|
|
168
|
+
type: 'post',
|
|
169
|
+
title: 'Hello world',
|
|
170
|
+
content: [],
|
|
171
|
+
excerpt: null,
|
|
172
|
+
cover_image_url: null,
|
|
173
|
+
author: { name: 'Adaeze Okafor', avatar_url: null },
|
|
174
|
+
published_at: '2026-09-15T12:00:00.000Z',
|
|
175
|
+
reading_time: 5,
|
|
176
|
+
...over,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const REL = [
|
|
181
|
+
post({ id: 'r1', slug: 'p-one' }),
|
|
182
|
+
post({ id: 'r2', slug: 'p-two' }),
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
/** Every covered state: mock setup + element. Keys match the JSON fixture. */
|
|
186
|
+
const STATES: Record<string, () => string> = {
|
|
187
|
+
'contact.out': () => { signedOut(); return render(<CheckoutContactSection />); },
|
|
188
|
+
'contact.in.initial': () => { signedIn(); return render(<CheckoutContactSection />); },
|
|
189
|
+
'contact.in.avatar': () => { signedIn({ user: AVATAR_USER }); return render(<CheckoutContactSection />); },
|
|
190
|
+
|
|
191
|
+
'shell.page.empty': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ totalCount: 0 })} />); },
|
|
192
|
+
'shell.page.full': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ totalCount: 3, subtotal: 4500, policies: POLICIES })} />); },
|
|
193
|
+
'shell.page.minimal': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ totalCount: 2, subtotal: 1000, addressPicker: null, shippingZonePicker: null, schedulePicker: null, deliveryMessage: { type: 'warning', message: 'Slow courier' } })} />); },
|
|
194
|
+
'shell.panel.summary.empty': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ mode: 'panel', activeTab: 'summary', totalCount: 0 })} />); },
|
|
195
|
+
'shell.panel.summary.items3': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ mode: 'panel', activeTab: 'summary', totalCount: 3, subtotal: 4500 })} />); },
|
|
196
|
+
'shell.panel.summary.items1': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ mode: 'panel', activeTab: 'summary', totalCount: 1, subtotal: 1500 })} />); },
|
|
197
|
+
'shell.panel.checkout': () => { signedOut(); return render(<DefaultCheckoutShell {...shellProps({ mode: 'panel', activeTab: 'checkout', totalCount: 2, subtotal: 2000 })} />); },
|
|
198
|
+
|
|
199
|
+
'policies.empty': () => { signedOut(); return render(<PolicyLinks policies={[]} />); },
|
|
200
|
+
'policies.list': () => { signedOut(); return render(<PolicyLinks policies={POLICIES} />); },
|
|
201
|
+
|
|
202
|
+
'login.default': () => { baseFlow(); return render(<AuthEmailLoginStep />); },
|
|
203
|
+
'login.loading': () => { baseFlow({ loading: true, email: 'ada@example.com', password: 'secret123' }); return render(<AuthEmailLoginStep />); },
|
|
204
|
+
'login.error': () => { baseFlow({ email: 'ada@example.com', password: 'secret123', error: 'Wrong email or password. Please try again.' }); return render(<AuthEmailLoginStep />); },
|
|
205
|
+
|
|
206
|
+
'otp.default': () => { baseFlow(); return render(<AuthEmailOtpStep />); },
|
|
207
|
+
'otp.loading': () => { baseFlow({ loading: true, email: 'ada@example.com' }); return render(<AuthEmailOtpStep />); },
|
|
208
|
+
'otp.error': () => { baseFlow({ email: 'ada@example.com', error: 'Something went wrong. Please try again.' }); return render(<AuthEmailOtpStep />); },
|
|
209
|
+
|
|
210
|
+
'verify.empty': () => { baseFlow({ email: 'ada@example.com', otp: '' }); return render(<AuthEmailOtpVerifyStep />); },
|
|
211
|
+
'verify.partial': () => { baseFlow({ email: 'ada@example.com', otp: '123' }); return render(<AuthEmailOtpVerifyStep />); },
|
|
212
|
+
'verify.loading': () => { baseFlow({ email: 'ada@example.com', otp: '123456', loading: true }); return render(<AuthEmailOtpVerifyStep />); },
|
|
213
|
+
'verify.error': () => { baseFlow({ email: 'ada@example.com', otp: '12', error: 'Incorrect code. Please try again.' }); return render(<AuthEmailOtpVerifyStep />); },
|
|
214
|
+
|
|
215
|
+
'register.default': () => { baseFlow(); return render(<AuthEmailRegisterStep />); },
|
|
216
|
+
'register.loading': () => { baseFlow({ loading: true, firstName: 'Adaeze', email: 'ada@example.com', password: 'secret123' }); return render(<AuthEmailRegisterStep />); },
|
|
217
|
+
'register.error': () => { baseFlow({ firstName: 'Adaeze', email: 'ada@example.com', password: 'secret123', error: 'Email already registered. Please sign in instead.' }); return render(<AuthEmailRegisterStep />); },
|
|
218
|
+
|
|
219
|
+
'signup.default': () => { baseFlow(); return render(<AuthSignupStep />); },
|
|
220
|
+
'signup.loading': () => { baseFlow({ loading: true, firstName: 'Adaeze', lastName: 'Okafor' }); return render(<AuthSignupStep />); },
|
|
221
|
+
'signup.error': () => { baseFlow({ firstName: 'Adaeze', error: 'Please enter your first name.' }); return render(<AuthSignupStep />); },
|
|
222
|
+
|
|
223
|
+
'filter.pills': () => render(<CategoryFilter categories={CATS} />),
|
|
224
|
+
'filter.pills.active': () => render(<CategoryFilter categories={CATS} active="news" />),
|
|
225
|
+
'filter.tabs': () => render(<CategoryFilter categories={CATS} variant="tabs" />),
|
|
226
|
+
'filter.tabs.active': () => render(<CategoryFilter categories={CATS} variant="tabs" active="recipes" />),
|
|
227
|
+
'filter.empty': () => render(<CategoryFilter categories={[]} />),
|
|
228
|
+
'filter.null': () => render(<CategoryFilter categories={null} />),
|
|
229
|
+
|
|
230
|
+
'page.mid': () => render(<BlogPagination pagination={{ current_page: 2, per_page: 12, has_more: true }} category="news" />),
|
|
231
|
+
'page.last': () => render(<BlogPagination pagination={{ current_page: 4, has_more: false }} />),
|
|
232
|
+
'page.first.next': () => render(<BlogPagination pagination={{ current_page: 1, has_more: true }} />),
|
|
233
|
+
'page.lone': () => render(<BlogPagination pagination={{ current_page: 1, has_more: false }} />),
|
|
234
|
+
'page.unset': () => render(<BlogPagination pagination={undefined} />),
|
|
235
|
+
|
|
236
|
+
'related.default': () => render(<RelatedPosts posts={REL as never} />),
|
|
237
|
+
'related.custom': () => render(<RelatedPosts posts={REL as never} heading="Keep reading" />),
|
|
238
|
+
'related.empty': () => render(<RelatedPosts posts={[]} />),
|
|
239
|
+
'related.null': () => render(<RelatedPosts posts={null} />),
|
|
240
|
+
|
|
241
|
+
'share.default': () => render(<ShareButtons title="Hello world" />),
|
|
242
|
+
|
|
243
|
+
'meta.full': () => render(<PostMeta post={post() as never} />),
|
|
244
|
+
'meta.reading1': () => render(<PostMeta post={post({ reading_time: 1 }) as never} />),
|
|
245
|
+
'meta.noauthor': () => render(<PostMeta post={post({ author: null }) as never} />),
|
|
246
|
+
'meta.minimal': () => render(<PostMeta post={post({ author: null, published_at: null, reading_time: 0 }) as never} />),
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const root = dirname(fileURLToPath(import.meta.url));
|
|
250
|
+
const BASELINES = JSON.parse(
|
|
251
|
+
readFileSync(join(root, 'fixtures', 'shopper-chrome-baseline.json'), 'utf-8'),
|
|
252
|
+
) as Record<string, string>;
|
|
253
|
+
|
|
254
|
+
describe('shopper chrome English safety (checkout/auth/blog)', () => {
|
|
255
|
+
it('covers every baseline state (fixture and STATES map stay in sync)', () => {
|
|
256
|
+
expect(Object.keys(STATES).sort()).toEqual(Object.keys(BASELINES).sort());
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
for (const [name, renderState] of Object.entries(STATES)) {
|
|
260
|
+
it(`${name}: byte-identical to the stored OLD markup`, () => {
|
|
261
|
+
expect(renderState()).toBe(BASELINES[name]);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// S4 value guard: ShareButtons renders its copied branch only after a
|
|
266
|
+
// client-side clipboard write (internal useState, no prop), so no static
|
|
267
|
+
// baseline can capture it. Pin the English through the real `t` instead —
|
|
268
|
+
// any change to blog.share.copied fails here.
|
|
269
|
+
it("share.copied: English value pinned ('Copied')", () => {
|
|
270
|
+
expect(t(defaultThemeStrings, 'blog.share.copied')).toBe('Copied');
|
|
271
|
+
});
|
|
272
|
+
});
|
|
@@ -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
|
+
});
|