@usequeek/theme-kit 0.1.20 → 0.1.22
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 +264 -77
- package/api/collections.ts +5 -5
- package/api/shop.ts +1 -1
- package/client.ts +2 -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 +9 -7
- package/components/checkout/default-checkout-shell.tsx +17 -15
- package/components/checkout/policy-links.tsx +5 -3
- package/components/image.tsx +5 -5
- package/components/language-switcher.css +131 -0
- package/components/language-switcher.tsx +144 -0
- package/components/markdown.tsx +1 -1
- package/components/powered-by-queek.tsx +4 -4
- package/components/product-media-frame.tsx +1 -1
- package/components/product-metafields.tsx +2 -3
- package/components/variant-picker.tsx +6 -9
- package/demo.ts +10 -14
- package/design-token-preview.tsx +0 -2
- package/edit-mode-active.tsx +3 -4
- package/hooks/use-announcement.ts +1 -1
- package/hooks/use-auth-flow.tsx +19 -18
- package/hooks/use-locales.ts +107 -0
- package/hooks/use-posts.ts +1 -1
- package/hooks/use-product-detail.ts +1 -2
- package/hooks/use-product-sections.ts +4 -6
- package/hooks/use-products.ts +5 -8
- package/index.ts +16 -15
- package/locales/en.default.json +49 -7
- package/next-reference.d.ts +2 -4
- package/package.json +3 -2
- package/page-renderer.tsx +5 -6
- package/provider.tsx +22 -2
- package/scripts/verify-package.ts +6 -8
- package/sdk/client.ts +13 -12
- package/sdk/error-handler.ts +1 -1
- package/sdk/platform.ts +6 -8
- package/sdk/queek-client.ts +11 -12
- package/sdk/server-store-client.ts +13 -17
- package/shared-blocks/metaobjects.tsx +1 -1
- package/stores/cart-store.ts +2 -2
- package/stores/checkout-store.ts +1 -1
- package/stores/edit-preview-store.ts +5 -6
- package/stores/user-store.ts +4 -5
- package/strings/pseudo-locale.ts +263 -0
- package/strings/theme-strings.ts +185 -20
- package/tests/auth-email-errors.test.tsx +55 -0
- package/tests/currency-contract.test.ts +2 -3
- package/tests/display-format.test.ts +356 -2
- package/tests/fixtures/external-theme.tsx +18 -2
- package/tests/language-switcher.test.tsx +350 -0
- package/tests/list-pagination.test.tsx +8 -8
- package/tests/locale-switch.test.ts +197 -0
- package/tests/locale-transport.test.tsx +3 -3
- package/tests/markdown-render.test.ts +5 -6
- package/tests/orders-walk.test.ts +5 -5
- package/tests/page-renderer-signature.test.tsx +1 -1
- package/tests/powered-by-queek.test.tsx +3 -3
- package/tests/powered-by-url.test.ts +2 -2
- package/tests/public-surface.test.ts +1 -4
- package/tests/shopper-chrome-strings.test.tsx +271 -0
- package/tests/storefront-client-scope.test.ts +5 -5
- package/tests/theme-hook-placement.test.tsx +179 -0
- package/tests/theme-strings-client.test.tsx +5 -5
- package/tests/theme-strings-pseudo.test.tsx +171 -0
- package/tests/theme-strings-typed.test.tsx +97 -0
- package/tests/theme-strings.test.tsx +10 -7
- package/tests/theme-typed-keys.fixture.ts +76 -0
- package/tests/use-shop-prefetch.test.tsx +1 -1
- package/theme-mount.tsx +6 -11
- package/types/block.ts +7 -7
- package/types/media.ts +8 -9
- package/types/product.ts +8 -12
- package/types/theme.ts +26 -25
- package/types/vendor.ts +10 -12
- package/utils/announcement.ts +3 -3
- package/utils/benefit-icon.tsx +9 -11
- package/utils/brand.ts +7 -10
- package/utils/format.ts +100 -14
- package/utils/image-srcset.ts +2 -2
- package/utils/locale-switch.ts +208 -0
- package/utils/locale.ts +56 -5
- package/utils/menu-link.ts +1 -1
- package/utils/product-media.ts +1 -1
- package/utils/product-normalizer.ts +2 -2
- package/utils/vendor-host.ts +7 -11
|
@@ -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: 'demo-store', name: 'Demo Store' } 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 check (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
|
+
});
|
|
@@ -43,7 +43,7 @@ const FR_THEME = {
|
|
|
43
43
|
'cart.empty.browse': 'Voir les produits',
|
|
44
44
|
} as unknown as ThemeStringsDictionary;
|
|
45
45
|
|
|
46
|
-
describe('t() fallback order
|
|
46
|
+
describe('t() fallback order', () => {
|
|
47
47
|
it('returns the first hit across override, theme, core, default', async () => {
|
|
48
48
|
const bound = await createThemeStrings({
|
|
49
49
|
locale: 'fr',
|
|
@@ -223,7 +223,7 @@ describe('RTL dir (locale catalogue)', () => {
|
|
|
223
223
|
});
|
|
224
224
|
});
|
|
225
225
|
|
|
226
|
-
describe('default English dictionary budget (
|
|
226
|
+
describe('default English dictionary budget (64-character stored key)', () => {
|
|
227
227
|
function flatten(node: unknown, prefix: string, out: Array<[string, string]>): void {
|
|
228
228
|
if (typeof node === 'string') {
|
|
229
229
|
out.push([prefix, node]);
|
|
@@ -239,9 +239,12 @@ 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 ceiling stays tight (+9 headroom): any further
|
|
244
|
+
// growth must bump it here deliberately with its own justification, which
|
|
245
|
+
// guards against uncontrolled growth (64-character stored-key budget below).
|
|
243
246
|
expect(pairs.length).toBeGreaterThanOrEqual(70);
|
|
244
|
-
expect(pairs.length).
|
|
247
|
+
expect(pairs.length).toBeLessThanOrEqual(115);
|
|
245
248
|
});
|
|
246
249
|
|
|
247
250
|
it('every key fits the dotted-lowercase grammar and the 40-char budget', () => {
|
|
@@ -251,7 +254,7 @@ describe('default English dictionary budget (backend varchar(64))', () => {
|
|
|
251
254
|
expect(key, `grammar: ${key}`).toMatch(/^[a-z0-9]+(\.[a-z0-9]+)*$/);
|
|
252
255
|
expect(key.length, `budget: ${key}`).toBeLessThanOrEqual(THEME_STRING_KEY_MAX_LENGTH);
|
|
253
256
|
}
|
|
254
|
-
// `<theme-slug>.<key>` must fit
|
|
257
|
+
// `<theme-slug>.<key>` must fit the 64-character stored key: 40 + 1 + 23.
|
|
255
258
|
expect(Math.max(...pairs.map(([k]) => k.length))).toBeLessThanOrEqual(40);
|
|
256
259
|
});
|
|
257
260
|
|
|
@@ -266,7 +269,7 @@ describe('default English dictionary budget (backend varchar(64))', () => {
|
|
|
266
269
|
});
|
|
267
270
|
});
|
|
268
271
|
|
|
269
|
-
describe('per-locale loading gate
|
|
272
|
+
describe('per-locale loading gate', () => {
|
|
270
273
|
function sourceFiles(dir: string, out: string[] = []): string[] {
|
|
271
274
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
272
275
|
if (entry.name === 'node_modules') continue;
|
|
@@ -329,7 +332,7 @@ describe('per-locale loading gate (decision 5)', () => {
|
|
|
329
332
|
});
|
|
330
333
|
});
|
|
331
334
|
|
|
332
|
-
describe('server surface: bound t with NO provider
|
|
335
|
+
describe('server surface: bound t with NO provider', () => {
|
|
333
336
|
function Greeting({ bound, name }: { bound: BoundThemeStrings; name: string }): JSX.Element {
|
|
334
337
|
return (
|
|
335
338
|
<main>
|
|
@@ -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);
|
|
@@ -34,7 +34,7 @@ function Grid(props: Parameters<typeof useShop>[0]): JSX.Element {
|
|
|
34
34
|
|
|
35
35
|
function render(children: ReactNode, opts: { prefetch?: ShopPrefetch | null; preview?: Product[] } = {}): string {
|
|
36
36
|
const store = {
|
|
37
|
-
vendor: { id: 'v1', slug: '
|
|
37
|
+
vendor: { id: 'v1', slug: 'demo-store', name: 'Demo Store' },
|
|
38
38
|
config: {},
|
|
39
39
|
menus: [],
|
|
40
40
|
previewData: opts.preview ? { products: opts.preview } : undefined,
|
package/theme-mount.tsx
CHANGED
|
@@ -7,18 +7,13 @@ import { ThemeProvider } from './theme-context';
|
|
|
7
7
|
/**
|
|
8
8
|
* Renders a theme. THE MINIMUM A THEME NEEDS TO BE SEEN.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* The component that does it, `VendorShell`, lives in the storefront app, so it
|
|
13
|
-
* was never part of what shipped. A theme could be written and typechecked from
|
|
14
|
-
* outside this repo but never rendered.
|
|
10
|
+
* It mounts a theme's header, page and footer inside the kit's provider, so a
|
|
11
|
+
* theme written and typechecked outside a Queek storefront can still be rendered.
|
|
15
12
|
*
|
|
16
|
-
* DELIBERATELY
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* into the package. What is left is the actual contract: a Layout wrapping a
|
|
21
|
-
* header, the page, and a footer.
|
|
13
|
+
* DELIBERATELY SMALL. It leaves out the Queek storefront's own chrome — checkout
|
|
14
|
+
* header, cart and auth modals, toasts, WhatsApp chat, analytics, the merchant
|
|
15
|
+
* edit-mode bridge. None of that is a theme's concern. What is left is the
|
|
16
|
+
* actual contract: a Layout wrapping a header, the page, and a footer.
|
|
22
17
|
*
|
|
23
18
|
* Themes render inside a boundary because a theme's header and footer are
|
|
24
19
|
* SIBLINGS of the page, so a crash in either escapes any boundary the page sets
|
package/types/block.ts
CHANGED
|
@@ -42,7 +42,7 @@ export interface GalleryBlockData {
|
|
|
42
42
|
heading?: string | null;
|
|
43
43
|
description?: string | null;
|
|
44
44
|
/** Block-level (not per-slide) hero display controls — currently only
|
|
45
|
-
*
|
|
45
|
+
* some themes' 'slider'/'thumb-rail' variants read these; harmless no-op
|
|
46
46
|
* elsewhere. */
|
|
47
47
|
zoom_effect?: boolean | null;
|
|
48
48
|
overlay?: 'none' | 'light' | 'medium' | 'strong' | null;
|
|
@@ -62,7 +62,7 @@ export interface GalleryBlockData {
|
|
|
62
62
|
/** YouTube/Vimeo/direct file URL — takes over from `url` as the slide's
|
|
63
63
|
* media when set, with `url` still used as the poster/fallback. Only
|
|
64
64
|
* takes effect on themes/variants that render a single full-bleed slide
|
|
65
|
-
* (e.g.
|
|
65
|
+
* (e.g. a `slider`/`thumb-rail` variant with exactly 1 image) — a
|
|
66
66
|
* multi-slide carousel would autoplay every slide's video concurrently
|
|
67
67
|
* since inactive slides stay mounted, not unmounted. */
|
|
68
68
|
video?: string | null;
|
|
@@ -258,7 +258,7 @@ export interface MetaobjectsBlockData {
|
|
|
258
258
|
* Server-seeded resolution for a `metaobjects` block — the entries of
|
|
259
259
|
* `data.type` plus the definition meta needed to decide whether each card
|
|
260
260
|
* links out (`definition.has_pages`). Mirrors `MetaobjectsResolved` shape
|
|
261
|
-
* used by `hydrateProductBlocks` in the
|
|
261
|
+
* used by `hydrateProductBlocks` in the host app.
|
|
262
262
|
*/
|
|
263
263
|
export interface MetaobjectsResolved {
|
|
264
264
|
entries: MetaobjectEntry[];
|
|
@@ -293,17 +293,17 @@ export type Block<T extends BlockType = BlockType> = {
|
|
|
293
293
|
/**
|
|
294
294
|
* Section-scoped design-token overrides — only the keys set here are
|
|
295
295
|
* applied; everything else inherits the global tokens via the CSS
|
|
296
|
-
* cascade. See `expandDesignTokensPartial` in `
|
|
296
|
+
* cascade. See `expandDesignTokensPartial` in `utils/brand.ts`.
|
|
297
297
|
*/
|
|
298
298
|
tokens?: Partial<DesignTokens>;
|
|
299
299
|
/**
|
|
300
300
|
* Stable in-page-scroll target — a menu item's `meta.anchor` links here.
|
|
301
|
-
* Lowercase-kebab, enforced by the
|
|
301
|
+
* Lowercase-kebab, enforced by the API.
|
|
302
302
|
*/
|
|
303
303
|
anchor_id?: string;
|
|
304
304
|
/**
|
|
305
305
|
* Server-seeded product data for `products` blocks — set by
|
|
306
|
-
* `hydrateProductBlocks`
|
|
306
|
+
* the host's `hydrateProductBlocks` before blocks reach
|
|
307
307
|
* `PageRenderer`, which awaits it via `use()` inside a per-block
|
|
308
308
|
* `<Suspense>` boundary. Lives on the block, not inside `data`, so it never
|
|
309
309
|
* gets spread into theme components as a prop. Absent for every other
|
|
@@ -312,7 +312,7 @@ export type Block<T extends BlockType = BlockType> = {
|
|
|
312
312
|
productsPromise?: Promise<Product[]>;
|
|
313
313
|
/**
|
|
314
314
|
* Server-seeded entries + definition meta for `metaobjects` blocks — set
|
|
315
|
-
* by `hydrateProductBlocks` alongside `productsPromise`. Same
|
|
315
|
+
* by the host's `hydrateProductBlocks` alongside `productsPromise`. Same rule:
|
|
316
316
|
* absent for every other block type and for any route that hasn't been
|
|
317
317
|
* wired to hydrate; the renderer falls back to rendering nothing (themes
|
|
318
318
|
* never fetch metaobjects client-side).
|
package/types/media.ts
CHANGED
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The srcset-ready tier map emitted by the
|
|
3
|
-
* (
|
|
4
|
-
*
|
|
5
|
-
* which are unchanged — this is purely additive.
|
|
2
|
+
* The srcset-ready tier map emitted by the API. Sits BESIDE the existing
|
|
3
|
+
* collapsed url fields (media.image, vendor.logo, collection.image, …), which
|
|
4
|
+
* are unchanged — this is purely additive.
|
|
6
5
|
*
|
|
7
|
-
* Contract (enforced
|
|
6
|
+
* Contract (enforced API-side, relied on here):
|
|
8
7
|
* 1. All 5 keys are ALWAYS present when the map itself is non-null.
|
|
9
8
|
* 2. A tier that doesn't exist is the WHOLE VALUE null — never a partial
|
|
10
9
|
* object, never a fallback url. Skip it; never synthesize a filename.
|
|
11
10
|
* 3. `original` is never null when the map is non-null. Modeled as
|
|
12
11
|
* `ImageVariantTier | null` anyway rather than a type-level invariant:
|
|
13
|
-
* making it non-nullable would buy zero runtime safety against
|
|
12
|
+
* making it non-nullable would buy zero runtime safety against an API
|
|
14
13
|
* regression while forcing every consumer through a `!` or a cast. The
|
|
15
14
|
* invariant is documented, not encoded.
|
|
16
15
|
* 4. `w`/`h` are `min(tier_cap, original.width)` and the proportional height —
|
|
17
16
|
* so on a small original SEVERAL TIERS LEGITIMATELY SHARE A WIDTH. That is
|
|
18
|
-
* correct
|
|
17
|
+
* correct API behavior, and the reason resolveImageSources() dedupes
|
|
19
18
|
* srcset candidates by width (duplicate `w` descriptors make a srcset
|
|
20
19
|
* non-deterministic).
|
|
21
20
|
* 5. `thumb` ALWAYS has `w: null, h: null` — it's a legacy 150→240px tier with
|
|
@@ -25,7 +24,7 @@
|
|
|
25
24
|
* url, SVG, some legacy uploads). Consumers fall back to the existing
|
|
26
25
|
* collapsed field, exactly as today.
|
|
27
26
|
*
|
|
28
|
-
* Tier caps (
|
|
27
|
+
* Tier caps (px):
|
|
29
28
|
* thumb ~240 (uncapped/legacy) · card 480 · card2x 800 · view 1600 · original raw
|
|
30
29
|
*/
|
|
31
30
|
export interface ImageVariantTier {
|
|
@@ -44,7 +43,7 @@ export type ImageVariants = {
|
|
|
44
43
|
|
|
45
44
|
/**
|
|
46
45
|
* The flat url→variants sidecar on a Page/Post response
|
|
47
|
-
* (
|
|
46
|
+
* (the `media_index` field). Keyed by the EXACT raw url
|
|
48
47
|
* string as it appears in that same response's `content[]` block JSON —
|
|
49
48
|
* block schemas were not changed, so a consumer looks up
|
|
50
49
|
* `mediaIndex[url] ?? null` per raw url it finds in block content.
|
package/types/product.ts
CHANGED
|
@@ -29,8 +29,7 @@ export interface ProductShop {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* One entry of the ordered `images` list
|
|
33
|
-
* `CustomerProductMedia::orderedImages()`): primary first,
|
|
32
|
+
* One entry of the ordered `images` list: primary first,
|
|
34
33
|
* `{id, url, alt, variants}`. The listing payload caps the list
|
|
35
34
|
* (`media.listing.images_limit`); detail/PDP carries the full list.
|
|
36
35
|
*/
|
|
@@ -53,11 +52,9 @@ export interface ProductMedia {
|
|
|
53
52
|
primary_variant_image?: string | null;
|
|
54
53
|
video_url?: string | null;
|
|
55
54
|
/** Falls back to `image` server-side when the vendor hasn't set an explicit
|
|
56
|
-
* poster
|
|
57
|
-
* whenever video_url is set. */
|
|
55
|
+
* poster — always a usable value whenever video_url is set. */
|
|
58
56
|
video_poster_url?: string | null;
|
|
59
|
-
/** Srcset tier map for `image` (
|
|
60
|
-
* buildDetailMediaPayload — present on BOTH list and detail responses).
|
|
57
|
+
/** Srcset tier map for `image` (present on BOTH list and detail responses).
|
|
61
58
|
* null when the primary image isn't Media-backed. */
|
|
62
59
|
image_variants?: ImageVariants | null;
|
|
63
60
|
/** Sibling to `primary_variant_image`, same contract. */
|
|
@@ -268,9 +265,8 @@ export interface LegacyProductLike {
|
|
|
268
265
|
vendor_id?: string;
|
|
269
266
|
thumbnail?: string | null;
|
|
270
267
|
image?: string | null;
|
|
271
|
-
// Sibling top-level fields on the real
|
|
272
|
-
//
|
|
273
|
-
// image/thumbnail which are sent in both places.
|
|
268
|
+
// Sibling top-level fields on the real API payload — NOT nested under
|
|
269
|
+
// `media` server-side, unlike image/thumbnail which are sent in both places.
|
|
274
270
|
video_url?: string | null;
|
|
275
271
|
video_poster_url?: string | null;
|
|
276
272
|
stock?: number;
|
|
@@ -296,9 +292,9 @@ export interface LegacyProductLike {
|
|
|
296
292
|
// also send it as a {optionName: value} map (normalizeVariantOptionValues handles both) —
|
|
297
293
|
// intersecting the two types directly collapses to an unsatisfiable type, hence the Omit.
|
|
298
294
|
option_values?: ProductVariantOptionValue[] | Record<string, string>;
|
|
299
|
-
// The
|
|
300
|
-
//
|
|
301
|
-
//
|
|
295
|
+
// The API sends a variant's own price/stock as FLAT fields, never nested
|
|
296
|
+
// under pricing/inventory — those nested keys only exist if some other
|
|
297
|
+
// caller pre-shapes the payload.
|
|
302
298
|
price?: number;
|
|
303
299
|
discount_price?: number;
|
|
304
300
|
compare_at_price?: number | null;
|