@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.
Files changed (94) hide show
  1. package/README.md +264 -77
  2. package/api/collections.ts +5 -5
  3. package/api/shop.ts +1 -1
  4. package/client.ts +2 -0
  5. package/components/auth/auth-email-login-step.tsx +9 -7
  6. package/components/auth/auth-email-otp-step.tsx +9 -7
  7. package/components/auth/auth-email-otp-verify-step.tsx +8 -6
  8. package/components/auth/auth-email-register-step.tsx +10 -8
  9. package/components/auth/auth-signup-step.tsx +8 -6
  10. package/components/blog/category-filter.tsx +6 -5
  11. package/components/blog/pagination.tsx +8 -7
  12. package/components/blog/post-meta.tsx +15 -8
  13. package/components/blog/related-posts.tsx +5 -3
  14. package/components/blog/share-buttons.tsx +10 -8
  15. package/components/checkout/checkout-contact.tsx +9 -7
  16. package/components/checkout/default-checkout-shell.tsx +17 -15
  17. package/components/checkout/policy-links.tsx +5 -3
  18. package/components/image.tsx +5 -5
  19. package/components/language-switcher.css +131 -0
  20. package/components/language-switcher.tsx +144 -0
  21. package/components/markdown.tsx +1 -1
  22. package/components/powered-by-queek.tsx +4 -4
  23. package/components/product-media-frame.tsx +1 -1
  24. package/components/product-metafields.tsx +2 -3
  25. package/components/variant-picker.tsx +6 -9
  26. package/demo.ts +10 -14
  27. package/design-token-preview.tsx +0 -2
  28. package/edit-mode-active.tsx +3 -4
  29. package/hooks/use-announcement.ts +1 -1
  30. package/hooks/use-auth-flow.tsx +19 -18
  31. package/hooks/use-locales.ts +107 -0
  32. package/hooks/use-posts.ts +1 -1
  33. package/hooks/use-product-detail.ts +1 -2
  34. package/hooks/use-product-sections.ts +4 -6
  35. package/hooks/use-products.ts +5 -8
  36. package/index.ts +16 -15
  37. package/locales/en.default.json +49 -7
  38. package/next-reference.d.ts +2 -4
  39. package/package.json +3 -2
  40. package/page-renderer.tsx +5 -6
  41. package/provider.tsx +22 -2
  42. package/scripts/verify-package.ts +6 -8
  43. package/sdk/client.ts +13 -12
  44. package/sdk/error-handler.ts +1 -1
  45. package/sdk/platform.ts +6 -8
  46. package/sdk/queek-client.ts +11 -12
  47. package/sdk/server-store-client.ts +13 -17
  48. package/shared-blocks/metaobjects.tsx +1 -1
  49. package/stores/cart-store.ts +2 -2
  50. package/stores/checkout-store.ts +1 -1
  51. package/stores/edit-preview-store.ts +5 -6
  52. package/stores/user-store.ts +4 -5
  53. package/strings/pseudo-locale.ts +263 -0
  54. package/strings/theme-strings.ts +185 -20
  55. package/tests/auth-email-errors.test.tsx +55 -0
  56. package/tests/currency-contract.test.ts +2 -3
  57. package/tests/display-format.test.ts +356 -2
  58. package/tests/fixtures/external-theme.tsx +18 -2
  59. package/tests/language-switcher.test.tsx +350 -0
  60. package/tests/list-pagination.test.tsx +8 -8
  61. package/tests/locale-switch.test.ts +197 -0
  62. package/tests/locale-transport.test.tsx +3 -3
  63. package/tests/markdown-render.test.ts +5 -6
  64. package/tests/orders-walk.test.ts +5 -5
  65. package/tests/page-renderer-signature.test.tsx +1 -1
  66. package/tests/powered-by-queek.test.tsx +3 -3
  67. package/tests/powered-by-url.test.ts +2 -2
  68. package/tests/public-surface.test.ts +1 -4
  69. package/tests/shopper-chrome-strings.test.tsx +271 -0
  70. package/tests/storefront-client-scope.test.ts +5 -5
  71. package/tests/theme-hook-placement.test.tsx +179 -0
  72. package/tests/theme-strings-client.test.tsx +5 -5
  73. package/tests/theme-strings-pseudo.test.tsx +171 -0
  74. package/tests/theme-strings-typed.test.tsx +97 -0
  75. package/tests/theme-strings.test.tsx +10 -7
  76. package/tests/theme-typed-keys.fixture.ts +76 -0
  77. package/tests/use-shop-prefetch.test.tsx +1 -1
  78. package/theme-mount.tsx +6 -11
  79. package/types/block.ts +7 -7
  80. package/types/media.ts +8 -9
  81. package/types/product.ts +8 -12
  82. package/types/theme.ts +26 -25
  83. package/types/vendor.ts +10 -12
  84. package/utils/announcement.ts +3 -3
  85. package/utils/benefit-icon.tsx +9 -11
  86. package/utils/brand.ts +7 -10
  87. package/utils/format.ts +100 -14
  88. package/utils/image-srcset.ts +2 -2
  89. package/utils/locale-switch.ts +208 -0
  90. package/utils/locale.ts +56 -5
  91. package/utils/menu-link.ts +1 -1
  92. package/utils/product-media.ts +1 -1
  93. package/utils/product-normalizer.ts +2 -2
  94. 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 (decision 3)', () => {
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 (backend varchar(64))', () => {
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 ~75-string core skeleton', () => {
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).toBeLessThan(100);
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 varchar(64): 40 + 1 + 23.
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 (decision 5)', () => {
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 (decision 4a)', () => {
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: 'kili-foods', name: 'Kili Foods' },
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
- * Without this, installing `@usequeek/theme-kit` gave a developer every hook,
11
- * store, type and shared block — and no way to actually mount what they built.
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 SMALLER THAN VendorShell. That composes the same three pieces and
17
- * then layers on the Queek storefront's own chrome — checkout header, cart and
18
- * auth modals, toasts, WhatsApp chat, analytics, the merchant edit-mode bridge.
19
- * None of that is a theme's concern, and pulling it in would drag the whole app
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
- * glow's 'slider'/'thumb-rail' variants read these; harmless no-op
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. glow's `slider`/`thumb-rail` with exactly 1 image) — a
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 storefront.
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 `lib/core/utils/brand.ts`.
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 backend (ValidStorefrontBlocks).
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` (lib/core/api/products.ts) before blocks reach
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 contract:
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 backend's MediaVariantMap
3
- * (queek_backend: app/Services/Media/MediaVariantMap.php). Sits BESIDE the
4
- * existing collapsed url fields (media.image, vendor.logo, collection.image, …),
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 backend-side, relied on here):
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 a backend
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 backend behavior, and the reason resolveImageSources() dedupes
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 (queek_backend config/media.php `variant_widths`):
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
- * (StorePageResource::toArray → 'media_index'). Keyed by the EXACT raw url
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 (backend
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 (ProductResource::resolveVideoPosterUrl) — always a usable value
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` (ProductResource::buildBaseMediaPayload /
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 backend payload (ProductResource::
272
- // buildBasePayload) — NOT nested under `media` server-side, unlike
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 backend sends a variant's own price/stock as FLAT fields (see
300
- // ProductResource::resolveShopVariants), never nested under pricing/inventory —
301
- // those nested keys only exist if some other caller pre-shapes the payload.
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;