@usequeek/theme-kit 0.1.21 → 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 (81) hide show
  1. package/README.md +187 -117
  2. package/api/collections.ts +5 -5
  3. package/api/shop.ts +1 -1
  4. package/client.ts +2 -0
  5. package/components/checkout/checkout-contact.tsx +3 -3
  6. package/components/image.tsx +5 -5
  7. package/components/language-switcher.css +131 -0
  8. package/components/language-switcher.tsx +144 -0
  9. package/components/markdown.tsx +1 -1
  10. package/components/powered-by-queek.tsx +4 -4
  11. package/components/product-media-frame.tsx +1 -1
  12. package/components/product-metafields.tsx +2 -3
  13. package/components/variant-picker.tsx +6 -9
  14. package/demo.ts +10 -14
  15. package/design-token-preview.tsx +0 -2
  16. package/edit-mode-active.tsx +3 -4
  17. package/hooks/use-announcement.ts +1 -1
  18. package/hooks/use-auth-flow.tsx +1 -1
  19. package/hooks/use-locales.ts +107 -0
  20. package/hooks/use-posts.ts +1 -1
  21. package/hooks/use-product-detail.ts +1 -2
  22. package/hooks/use-product-sections.ts +4 -6
  23. package/hooks/use-products.ts +5 -8
  24. package/index.ts +13 -16
  25. package/locales/en.default.json +4 -0
  26. package/next-reference.d.ts +2 -4
  27. package/package.json +3 -2
  28. package/page-renderer.tsx +5 -6
  29. package/provider.tsx +22 -2
  30. package/scripts/verify-package.ts +6 -8
  31. package/sdk/client.ts +13 -12
  32. package/sdk/error-handler.ts +1 -1
  33. package/sdk/platform.ts +6 -8
  34. package/sdk/queek-client.ts +11 -12
  35. package/sdk/server-store-client.ts +13 -17
  36. package/shared-blocks/metaobjects.tsx +1 -1
  37. package/stores/cart-store.ts +2 -2
  38. package/stores/checkout-store.ts +1 -1
  39. package/stores/edit-preview-store.ts +5 -6
  40. package/stores/user-store.ts +4 -5
  41. package/strings/pseudo-locale.ts +3 -4
  42. package/strings/theme-strings.ts +9 -7
  43. package/tests/auth-email-errors.test.tsx +2 -2
  44. package/tests/currency-contract.test.ts +2 -3
  45. package/tests/display-format.test.ts +17 -25
  46. package/tests/fixtures/external-theme.tsx +18 -2
  47. package/tests/language-switcher.test.tsx +350 -0
  48. package/tests/list-pagination.test.tsx +8 -8
  49. package/tests/locale-switch.test.ts +197 -0
  50. package/tests/locale-transport.test.tsx +3 -3
  51. package/tests/markdown-render.test.ts +5 -6
  52. package/tests/orders-walk.test.ts +5 -5
  53. package/tests/page-renderer-signature.test.tsx +1 -1
  54. package/tests/powered-by-queek.test.tsx +3 -3
  55. package/tests/powered-by-url.test.ts +2 -2
  56. package/tests/public-surface.test.ts +1 -4
  57. package/tests/shopper-chrome-strings.test.tsx +5 -6
  58. package/tests/storefront-client-scope.test.ts +5 -5
  59. package/tests/theme-hook-placement.test.tsx +2 -2
  60. package/tests/theme-strings-client.test.tsx +5 -5
  61. package/tests/theme-strings-pseudo.test.tsx +1 -1
  62. package/tests/theme-strings-typed.test.tsx +1 -1
  63. package/tests/theme-strings.test.tsx +8 -11
  64. package/tests/use-shop-prefetch.test.tsx +1 -1
  65. package/theme-mount.tsx +6 -11
  66. package/types/block.ts +7 -7
  67. package/types/media.ts +8 -9
  68. package/types/product.ts +8 -12
  69. package/types/theme.ts +26 -25
  70. package/types/vendor.ts +10 -12
  71. package/utils/announcement.ts +3 -3
  72. package/utils/benefit-icon.tsx +9 -11
  73. package/utils/brand.ts +7 -10
  74. package/utils/format.ts +5 -7
  75. package/utils/image-srcset.ts +2 -2
  76. package/utils/locale-switch.ts +208 -0
  77. package/utils/locale.ts +10 -11
  78. package/utils/menu-link.ts +1 -1
  79. package/utils/product-media.ts +1 -1
  80. package/utils/product-normalizer.ts +2 -2
  81. package/utils/vendor-host.ts +7 -11
@@ -0,0 +1,197 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ defaultStoreLocale,
4
+ localeHref,
5
+ normalizeStoreLocales,
6
+ resolveActiveLocale,
7
+ type StoreLocale,
8
+ type StoreLocaleInput,
9
+ } from '../utils/locale-switch';
10
+
11
+ function row(locale: string, extra: Partial<StoreLocaleInput> = {}): StoreLocaleInput {
12
+ return { locale, name: locale, native_name: locale, is_primary: false, ...extra };
13
+ }
14
+
15
+ /** English default, French and Simplified Chinese under prefixes. */
16
+ const EN_DEFAULT = normalizeStoreLocales([
17
+ row('en', { name: 'English', native_name: 'English', is_primary: true, is_default: true, path_prefix: '' }),
18
+ row('fr', { name: 'French', native_name: 'Français', is_default: false, path_prefix: 'fr' }),
19
+ row('zh-CN', { name: 'Chinese', native_name: '简体中文', is_default: false, path_prefix: 'zh-cn' }),
20
+ ]);
21
+
22
+ /** French default; English (the primary) is not the default, so it lives under /en. */
23
+ const FR_DEFAULT = normalizeStoreLocales([
24
+ row('fr', { name: 'French', native_name: 'Français', is_default: true, path_prefix: '' }),
25
+ row('en', { name: 'English', native_name: 'English', is_primary: true, is_default: false, path_prefix: 'en' }),
26
+ ]);
27
+
28
+ function pick(locales: StoreLocale[], code: string): StoreLocale {
29
+ const found = locales.find((l) => l.locale === code);
30
+ if (!found) throw new Error(`fixture has no ${code}`);
31
+ return found;
32
+ }
33
+
34
+ function href(
35
+ locales: StoreLocale[],
36
+ from: string,
37
+ to: string,
38
+ url: { pathname: string; search?: string; hash?: string; basePath?: string },
39
+ ): string {
40
+ return localeHref({ ...url, active: pick(locales, from), target: pick(locales, to) });
41
+ }
42
+
43
+ describe('localeHref', () => {
44
+ it('moves the default language to a prefix and back to the root', () => {
45
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop' })).toBe('/fr/shop');
46
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr/shop', basePath: '/fr' })).toBe('/shop');
47
+ });
48
+
49
+ it('keeps the root path at the root of each language', () => {
50
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/' })).toBe('/fr');
51
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr', basePath: '/fr' })).toBe('/');
52
+ });
53
+
54
+ it('puts English under /en when English is not the default', () => {
55
+ expect(href(FR_DEFAULT, 'fr', 'en', { pathname: '/shop' })).toBe('/en/shop');
56
+ expect(href(FR_DEFAULT, 'en', 'fr', { pathname: '/en/shop', basePath: '/en' })).toBe('/shop');
57
+ });
58
+
59
+ it('uses the lower-case code as the prefix (zh-CN -> /zh-cn)', () => {
60
+ expect(href(EN_DEFAULT, 'en', 'zh-CN', { pathname: '/products/mug' })).toBe('/zh-cn/products/mug');
61
+ expect(href(EN_DEFAULT, 'zh-CN', 'fr', { pathname: '/zh-cn/products/mug', basePath: '/zh-cn' })).toBe('/fr/products/mug');
62
+ });
63
+
64
+ it('switches directly between two prefixed languages', () => {
65
+ expect(href(EN_DEFAULT, 'fr', 'zh-CN', { pathname: '/fr/shop/all', basePath: '/fr' })).toBe('/zh-cn/shop/all');
66
+ });
67
+
68
+ it('keeps the query string and the hash', () => {
69
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop', search: '?sort=new&page=2', hash: '#reviews' })).toBe(
70
+ '/fr/shop?sort=new&page=2#reviews',
71
+ );
72
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr', basePath: '/fr', search: '?q=a', hash: '#top' })).toBe('/?q=a#top');
73
+ });
74
+
75
+ it('accepts a query string and hash without their leading marks, and ignores empty ones', () => {
76
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop', search: 'a=1', hash: 'x' })).toBe('/fr/shop?a=1#x');
77
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop', search: '?', hash: '#' })).toBe('/fr/shop');
78
+ });
79
+
80
+ it('keeps a trailing slash on a nested path', () => {
81
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/shop/' })).toBe('/fr/shop/');
82
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr/shop/mugs/', basePath: '/fr' })).toBe('/shop/mugs/');
83
+ });
84
+
85
+ it('only strips whole segments: a page that starts like a language is a page', () => {
86
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr/french-press', basePath: '/fr' })).toBe('/french-press');
87
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/french-press' })).toBe('/fr/french-press');
88
+ // Active is the default (no prefix to strip): `/fr` as a path is a page there.
89
+ expect(href(EN_DEFAULT, 'en', 'zh-CN', { pathname: '/fr-news' })).toBe('/zh-cn/fr-news');
90
+ });
91
+
92
+ it('strips the active prefix once: a page slug equal to the prefix survives', () => {
93
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/fr/fr', basePath: '/fr' })).toBe('/fr');
94
+ });
95
+
96
+ it('handles a path-based host (basePath carries the store slug)', () => {
97
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/acme/shop', basePath: '/acme' })).toBe('/acme/fr/shop');
98
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/acme/fr/shop', basePath: '/acme/fr' })).toBe('/acme/shop');
99
+ expect(href(EN_DEFAULT, 'fr', 'zh-CN', { pathname: '/acme/fr', basePath: '/acme/fr' })).toBe('/acme/zh-cn');
100
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/acme/fr', basePath: '/acme/fr' })).toBe('/acme');
101
+ });
102
+
103
+ it('also copes with a non-localized basePath', () => {
104
+ expect(href(EN_DEFAULT, 'fr', 'en', { pathname: '/acme/fr/shop', basePath: '/acme' })).toBe('/acme/shop');
105
+ expect(href(EN_DEFAULT, 'en', 'fr', { pathname: '/acme', basePath: '/acme' })).toBe('/acme/fr');
106
+ });
107
+
108
+ it('matches the active prefix case-insensitively', () => {
109
+ expect(href(EN_DEFAULT, 'zh-CN', 'en', { pathname: '/ZH-CN/shop' })).toBe('/shop');
110
+ });
111
+
112
+ it('works on an old backend without is_default (primary is the default)', () => {
113
+ const old = normalizeStoreLocales([
114
+ { locale: 'en', name: 'English', native_name: 'English', is_primary: true, path_prefix: '' },
115
+ { locale: 'fr', name: 'French', native_name: 'Français', is_primary: false, path_prefix: 'fr' },
116
+ ]);
117
+ expect(old.map((l) => [l.locale, l.is_default, l.path_prefix])).toEqual([
118
+ ['en', true, ''],
119
+ ['fr', false, 'fr'],
120
+ ]);
121
+ expect(href(old, 'en', 'fr', { pathname: '/shop' })).toBe('/fr/shop');
122
+ expect(href(old, 'fr', 'en', { pathname: '/fr/shop', basePath: '/fr' })).toBe('/shop');
123
+ });
124
+ });
125
+
126
+ describe('normalizeStoreLocales', () => {
127
+ it('returns [] for nothing usable', () => {
128
+ expect(normalizeStoreLocales(undefined)).toEqual([]);
129
+ expect(normalizeStoreLocales(null)).toEqual([]);
130
+ expect(normalizeStoreLocales([])).toEqual([]);
131
+ expect(normalizeStoreLocales([{} as never, { locale: '' }, null as never])).toEqual([]);
132
+ expect(normalizeStoreLocales('fr' as never)).toEqual([]);
133
+ });
134
+
135
+ it('puts the default first and keeps the rest in order', () => {
136
+ const out = normalizeStoreLocales([
137
+ row('fr', { path_prefix: 'fr' }),
138
+ row('en', { is_primary: true, is_default: true }),
139
+ row('de', { path_prefix: 'de' }),
140
+ ]);
141
+ expect(out.map((l) => l.locale)).toEqual(['en', 'fr', 'de']);
142
+ });
143
+
144
+ it('keeps exactly one default, forces its prefix empty and fills empty prefixes', () => {
145
+ const out = normalizeStoreLocales([
146
+ row('en', { is_default: true, path_prefix: 'en' }),
147
+ row('FR', { is_default: true, path_prefix: '' }),
148
+ row('pt-BR', { path_prefix: '' }),
149
+ ]);
150
+ expect(out.map((l) => [l.locale, l.is_default, l.path_prefix])).toEqual([
151
+ ['en', true, ''],
152
+ ['FR', false, 'fr'],
153
+ ['pt-BR', false, 'pt-br'],
154
+ ]);
155
+ });
156
+
157
+ it('drops repeated codes, tolerates extra fields and defaults missing ones', () => {
158
+ const out = normalizeStoreLocales([
159
+ { locale: 'en', is_primary: true, extra: 'ignored' } as StoreLocaleInput,
160
+ { locale: 'EN', is_primary: false },
161
+ { locale: 'ar', path_prefix: 'ar', dir: 'rtl' },
162
+ ]);
163
+ expect(out).toHaveLength(2);
164
+ expect(out[0]).toMatchObject({ locale: 'en', name: 'en', native_name: 'en', dir: 'ltr', hreflang: 'en', html_lang: 'en' });
165
+ expect(out[0]).not.toHaveProperty('extra');
166
+ expect(out[1]?.dir).toBe('rtl');
167
+ });
168
+
169
+ it('accepts the storefront row shape without mapping', () => {
170
+ const storefrontRows: Array<{
171
+ locale: string; name: string; native_name: string; is_primary: boolean; is_default: boolean;
172
+ path_prefix: string; hreflang: string; html_lang: string; dir: string;
173
+ }> = [
174
+ { locale: 'en', name: 'English', native_name: 'English', is_primary: true, is_default: true, path_prefix: '', hreflang: 'en', html_lang: 'en', dir: 'ltr' },
175
+ ];
176
+ const accepted: readonly StoreLocaleInput[] = storefrontRows;
177
+ const exact: StoreLocale = normalizeStoreLocales(accepted)[0]!;
178
+ // A storefront-typed value is assignable to StoreLocale as well.
179
+ const back: typeof storefrontRows[number] = exact;
180
+ expect(back.locale).toBe('en');
181
+ });
182
+ });
183
+
184
+ describe('active and default resolution', () => {
185
+ it('resolves the request locale case-insensitively, unknown/null to the default', () => {
186
+ expect(resolveActiveLocale(EN_DEFAULT, 'zh-cn').locale).toBe('zh-CN');
187
+ expect(resolveActiveLocale(EN_DEFAULT, 'fr').locale).toBe('fr');
188
+ expect(resolveActiveLocale(EN_DEFAULT, null).locale).toBe('en');
189
+ expect(resolveActiveLocale(EN_DEFAULT, 'xx').locale).toBe('en');
190
+ expect(resolveActiveLocale(FR_DEFAULT, undefined).locale).toBe('fr');
191
+ });
192
+
193
+ it('falls back to English for an empty list', () => {
194
+ expect(defaultStoreLocale([]).locale).toBe('en');
195
+ expect(resolveActiveLocale([], 'fr').path_prefix).toBe('');
196
+ });
197
+ });
@@ -16,7 +16,7 @@ import { normalizeShopQuery, type ShopPrefetch } from '../utils/shop-query';
16
16
  import type { Product } from '../types/product';
17
17
 
18
18
  /**
19
- * Theme-kit locale transport (slice K1): the backend answers `?locale=<code>`
19
+ * Theme-kit locale transport: the API answers `?locale=<code>`
20
20
  * with translated text, the proxy sets `x-queek-locale` for published
21
21
  * non-primary locales only, and every kit transport forwards it additively —
22
22
  * no locale anywhere means byte-identical behaviour to before.
@@ -194,7 +194,7 @@ describe('server transport (sdk/server-store-client)', () => {
194
194
  });
195
195
 
196
196
  describe('fetchShopPrefetch (api/shop)', () => {
197
- async function runPrefetch(vendorSlug = 'kili-foods', locale?: string) {
197
+ async function runPrefetch(vendorSlug = 'demo-store', locale?: string) {
198
198
  const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ ok: true, json: async () => ({ data: [], has_more: false }) }));
199
199
  vi.stubGlobal('fetch', fetchMock);
200
200
  const { fetchShopPrefetch } = await import('../api/shop');
@@ -226,7 +226,7 @@ function product(id: string, title: string): Product {
226
226
  function render(children: ReactNode, locale?: string | null): string {
227
227
  return renderToStaticMarkup(
228
228
  <StorefrontProvider
229
- vendor={{ id: 'v1', slug: 'kili-foods', name: 'Kili Foods' } as never}
229
+ vendor={{ id: 'v1', slug: 'demo-store', name: 'Demo Store' } as never}
230
230
  config={{} as never}
231
231
  menus={[]}
232
232
  {...(locale !== undefined ? { locale } : {})}
@@ -4,12 +4,11 @@ import { describe, expect, it } from 'vitest';
4
4
  import { renderMarkdown } from '../utils/markdown';
5
5
 
6
6
  /**
7
- * Backend CT16 (queek_backend/.agent/frontend-chat/sessions/storefront-contact-block/chat.json):
8
- * a heading immediately followed by other content, with no blank line between them, gets
9
- * swallowed whole into the heading — `utils/markdown.tsx` used to treat an entire
10
- * blank-line-delimited block as heading text the moment it matched a heading prefix. Real
11
- * merchant copy (Jhema Wears' short_description) writes "### Our Offerings\n- item\n- item"
12
- * with no blank line, which is common, valid markdown — the list must not disappear into the h3.
7
+ * A heading immediately followed by other content, with no blank line between them, must
8
+ * not be swallowed whole into the heading — `utils/markdown.tsx` once treated an entire
9
+ * blank-line-delimited block as heading text the moment it matched a heading prefix.
10
+ * Merchant copy often writes "### Our Offerings\n- item\n- item" with no blank line, which
11
+ * is common, valid markdown — the list must not disappear into the h3.
13
12
  */
14
13
 
15
14
  function html(markdown: string): string {
@@ -2,9 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
2
2
  import { fetchOrders } from '../api/orders';
3
3
 
4
4
  /**
5
- * `GET store/orders` answers the ONE storefront list envelope (pagination
6
- * Slice 4b, founder 28/9/26): `has_more` + `next_cursor` top-level, no
7
- * `links`, no paginator `meta`. The kit reads the page it asked for.
5
+ * `GET store/orders` answers the ONE storefront list envelope: `has_more` +
6
+ * `next_cursor` top-level, no `links`, no paginator `meta`. The kit reads the
7
+ * page it asked for.
8
8
  */
9
9
  describe('fetchOrders walk state', () => {
10
10
  afterEach(() => vi.unstubAllGlobals());
@@ -23,7 +23,7 @@ describe('fetchOrders walk state', () => {
23
23
  }));
24
24
  vi.stubGlobal('fetch', fetchMock);
25
25
 
26
- const page = await fetchOrders('kili-foods', 2, 'ongoing');
26
+ const page = await fetchOrders('demo-store', 2, 'ongoing');
27
27
 
28
28
  const url = String((fetchMock.mock.calls[0] as unknown[])[0]);
29
29
  expect(url).toContain('/client/store/orders');
@@ -39,7 +39,7 @@ describe('fetchOrders walk state', () => {
39
39
  const fetchMock = vi.fn(async () => ({ ok: true, json: async () => ({ data: [], has_more: false, next_cursor: null }) }));
40
40
  vi.stubGlobal('fetch', fetchMock);
41
41
 
42
- const page = await fetchOrders('kili-foods', 1, 'all');
42
+ const page = await fetchOrders('demo-store', 1, 'all');
43
43
 
44
44
  expect(String((fetchMock.mock.calls[0] as unknown[])[0])).not.toContain('status=');
45
45
  expect(page.has_more).toBe(false);
@@ -27,7 +27,7 @@ describe('PageRenderer', () => {
27
27
  { type: 'content', data: { markdown: 'Fresh meals, delivered' }, productsPromise: circularThenable(), metaobjectsPromise: circularThenable() },
28
28
  ] as unknown as Block[];
29
29
  const theme = { getBlock: () => () => null } as unknown as ThemeModule;
30
- const store = { vendor: { id: 'v1', slug: 'kili-foods' }, config: {}, menus: [], basePath: '', pagesChrome: {} };
30
+ const store = { vendor: { id: 'v1', slug: 'demo-store' }, config: {}, menus: [], basePath: '', pagesChrome: {} };
31
31
 
32
32
  const html = renderToStaticMarkup(
33
33
  <StorefrontContext.Provider value={store as never}>
@@ -27,9 +27,9 @@ describe('PoweredByQueek', () => {
27
27
  });
28
28
 
29
29
  it('tags the link with the store slug as utm_campaign', () => {
30
- const html = render(undefined, { id: 'uuid-1', slug: 'kili-foods' });
30
+ const html = render(undefined, { id: 'uuid-1', slug: 'demo-store' });
31
31
 
32
- expect(html).toContain(`href="${BASE}&amp;utm_campaign=kili-foods"`);
32
+ expect(html).toContain(`href="${BASE}&amp;utm_campaign=demo-store"`);
33
33
  expect(html).toContain('rel="noopener noreferrer"');
34
34
  });
35
35
 
@@ -60,6 +60,6 @@ describe('PoweredByQueek', () => {
60
60
 
61
61
  it('renders nothing when hidden', () => {
62
62
  expect(render({ hidden: true })).toBe('');
63
- expect(render({ hidden: true }, { id: 'uuid-1', slug: 'kili-foods' })).toBe('');
63
+ expect(render({ hidden: true }, { id: 'uuid-1', slug: 'demo-store' })).toBe('');
64
64
  });
65
65
  });
@@ -7,13 +7,13 @@ function params(url: string): Record<string, string> {
7
7
 
8
8
  describe('poweredByQueekUrl', () => {
9
9
  it('keeps the business-page destination and adds source, medium and campaign', () => {
10
- const url = poweredByQueekUrl('kili-foods');
10
+ const url = poweredByQueekUrl('demo-store');
11
11
 
12
12
  expect(url.startsWith(`${POWERED_BY_QUEEK_URL}?`)).toBe(true);
13
13
  expect(params(url)).toEqual({
14
14
  utm_source: 'powered_by',
15
15
  utm_medium: 'storefront',
16
- utm_campaign: 'kili-foods',
16
+ utm_campaign: 'demo-store',
17
17
  });
18
18
  });
19
19
 
@@ -20,10 +20,7 @@ function barrelModules(file: string): Set<string> {
20
20
  * `index.ts` + `client.ts` declare the surface a theme may build against — the
21
21
  * set that ships as `@usequeek/theme-kit`.
22
22
  *
23
- * This is the kit-internal half of the contract: every module the barrels
24
- * declare must exist. (The storefront repo holds the other half —
25
- * `core-public-surface.test.ts` there asserts the barrels cover every module
26
- * the themes actually import.)
23
+ * Every module the barrels declare must exist.
27
24
  */
28
25
  describe('core public surface (kit-internal)', () => {
29
26
  const declared = new Set([...barrelModules('index.ts'), ...barrelModules('client.ts')]);
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Shopper-chrome theme strings (checkout / auth / blog) — English-safety guard.
3
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.
4
+ * Each state below is rendered and compared with the markup stored in
5
+ * `tests/fixtures/shopper-chrome-baseline.json` (`.toBe` equality), so ANY
6
+ * English change (value, markup, attribute, plural) fails here.
8
7
  */
9
8
  import { readFileSync } from 'node:fs';
10
9
  import { join, dirname } from 'node:path';
@@ -112,7 +111,7 @@ const AVATAR_USER = {
112
111
  function render(children: ReactNode): string {
113
112
  return renderToStaticMarkup(
114
113
  <StorefrontProvider
115
- vendor={{ id: 'v1', slug: 'kili-foods', name: 'Kili Foods' } as never}
114
+ vendor={{ id: 'v1', slug: 'demo-store', name: 'Demo Store' } as never}
116
115
  config={{} as never}
117
116
  menus={[]}
118
117
  >
@@ -262,7 +261,7 @@ describe('shopper chrome English safety (checkout/auth/blog)', () => {
262
261
  });
263
262
  }
264
263
 
265
- // S4 value guard: ShareButtons renders its copied branch only after a
264
+ // Value guard: ShareButtons renders its copied branch only after a
266
265
  // client-side clipboard write (internal useState, no prop), so no static
267
266
  // baseline can capture it. Pin the English through the real `t` instead —
268
267
  // any change to blog.share.copied fails here.
@@ -4,11 +4,11 @@ import { join } from 'node:path';
4
4
  import { request } from '../sdk/client';
5
5
 
6
6
  /**
7
- * The storefront must ONLY ever call the `/v1/client/*` backend scope. That scope's
8
- * ResolveClientContext middleware resolves the vendor AND forces the platform
9
- * (storefront/instore_qr) — so prices are always the storefront's, never the
10
- * marketplace commission-inclusive price. Legacy v1 routes (/products, /vendors/*,
11
- * /categories, ...) bypass that middleware → wrong platform → wrong price.
7
+ * The storefront must ONLY ever call the `/v1/client/*` API scope. That scope
8
+ * resolves the vendor AND forces the platform (storefront/instore_qr) — so prices
9
+ * are always the storefront's, never the marketplace commission-inclusive price.
10
+ * Legacy v1 routes (/products, /vendors/*, /categories, ...) skip that
11
+ * resolution → wrong platform → wrong price.
12
12
  *
13
13
  * This guard prevents that whole class of bug from ever returning.
14
14
  */
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Hook-placement rule (checkout / auth / blog slice) — S2 enforcement.
2
+ * Hook-placement rule for the checkout / auth / blog components.
3
3
  *
4
4
  * Rule: every `useThemeStrings()` call must sit at the top level of a
5
5
  * PascalCase component or a `use*` hook: no enclosing if / ternary / loop /
@@ -15,7 +15,7 @@ import ts from 'typescript';
15
15
 
16
16
  const kitRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
17
17
 
18
- // Files this slice migrated: each must call useThemeStrings() exactly once.
18
+ // Files that use theme strings: each must call useThemeStrings() exactly once.
19
19
  const EXPECTED_FILES = [
20
20
  'components/checkout/checkout-contact.tsx',
21
21
  'components/checkout/default-checkout-shell.tsx',
@@ -23,7 +23,7 @@ function render(
23
23
  ): string {
24
24
  return renderToStaticMarkup(
25
25
  <StorefrontProvider
26
- vendor={{ id: 'v1', slug: 'kili-foods', name: 'Kili Foods' } as never}
26
+ vendor={{ id: 'v1', slug: 'demo-store', name: 'Demo Store' } as never}
27
27
  config={{} as never}
28
28
  menus={[]}
29
29
  {...(options?.locale !== undefined ? { locale: options.locale } : {})}
@@ -39,7 +39,7 @@ function Title(): JSX.Element {
39
39
  return <h1>{t('cart.title')}</h1>;
40
40
  }
41
41
 
42
- describe('client surface: StorefrontProvider strings + useThemeStrings (decision 4b)', () => {
42
+ describe('client surface: StorefrontProvider strings + useThemeStrings', () => {
43
43
  it('renders the active locale through the hook', () => {
44
44
  expect(render(<Title />, { locale: 'fr', strings: FR_STRINGS })).toContain('<h1>Votre panier</h1>');
45
45
  });
@@ -105,7 +105,7 @@ describe('client surface through ThemeMount', () => {
105
105
  });
106
106
  });
107
107
 
108
- describe('manifest.strings travels with the theme (decision 15)', () => {
108
+ describe('manifest.strings travels with the theme', () => {
109
109
  const MANIFEST_EN: ThemeStringsDictionary = {
110
110
  theme: {
111
111
  hero: { title: 'Fresh from the farm' },
@@ -216,10 +216,10 @@ describe('manifest.strings travels with the theme (decision 15)', () => {
216
216
  // No `strings` key at all: this literal must satisfy StorefrontContextValue
217
217
  // (it failed to compile while the field was required in 0.1.18).
218
218
  const value = {
219
- vendor: { id: 'v1', slug: 'kili-foods', name: 'Kili Foods', logo: null } as VendorProfile,
219
+ vendor: { id: 'v1', slug: 'demo-store', name: 'Demo Store', logo: null } as VendorProfile,
220
220
  config: { theme: 'stub', brand: {} } as StorefrontConfig,
221
221
  menus: [],
222
- basePath: '/kili-foods',
222
+ basePath: '/demo-store',
223
223
  pagesChrome: {},
224
224
  locale: null,
225
225
  };
@@ -17,7 +17,7 @@ const EN = enDefault as unknown as ThemeStringsDictionary;
17
17
  function render(children: ReactNode, strings: ThemeStringsDictionary | null): string {
18
18
  return renderToStaticMarkup(
19
19
  <StorefrontProvider
20
- vendor={{ id: 'v1', slug: 'kili-foods', name: 'Kili Foods' } as never}
20
+ vendor={{ id: 'v1', slug: 'demo-store', name: 'Demo Store' } as never}
21
21
  config={{} as never}
22
22
  menus={[]}
23
23
  {...(strings ? { strings } : {})}
@@ -79,7 +79,7 @@ describe('typed keys: createThemeT (additive, opt-in)', () => {
79
79
  });
80
80
 
81
81
  it('drifts with the dictionary: the union is exactly the flattened keys', () => {
82
- // Compile-time proof (positive direction; the negative direction — typos
82
+ // Compile-time check (positive direction; the negative direction — typos
83
83
  // fail — lives in tests/theme-typed-keys.fixture.ts via @ts-expect-error
84
84
  // so `npm run typecheck` stays green).
85
85
  const keys: Array<ThemeStringKey<typeof THEME_EN>> = [
@@ -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]);
@@ -240,12 +240,9 @@ describe('default English dictionary budget (backend varchar(64))', () => {
240
240
  flatten(enDefault, '', pairs);
241
241
 
242
242
  it('ships the ~106-string core dictionary (intentional growth, still budgeted)', () => {
243
- // Dictionary budget: the checkout/auth/blog shopper-chrome slice added 31
244
- // keys and removed 1 orphaned pre-existing key (auth.code.sent, 0 usages,
245
- // superseded by auth.code.lead): 76 -> 106, commit 5cb67dd + orphan removal.
246
- // The ceiling stays tight (+9 headroom): any further growth must bump it
247
- // here deliberately with its own justification, which keeps guarding
248
- // against uncontrolled growth (backend varchar(64) per-key budget below).
243
+ // 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).
249
246
  expect(pairs.length).toBeGreaterThanOrEqual(70);
250
247
  expect(pairs.length).toBeLessThanOrEqual(115);
251
248
  });
@@ -257,7 +254,7 @@ describe('default English dictionary budget (backend varchar(64))', () => {
257
254
  expect(key, `grammar: ${key}`).toMatch(/^[a-z0-9]+(\.[a-z0-9]+)*$/);
258
255
  expect(key.length, `budget: ${key}`).toBeLessThanOrEqual(THEME_STRING_KEY_MAX_LENGTH);
259
256
  }
260
- // `<theme-slug>.<key>` must fit varchar(64): 40 + 1 + 23.
257
+ // `<theme-slug>.<key>` must fit the 64-character stored key: 40 + 1 + 23.
261
258
  expect(Math.max(...pairs.map(([k]) => k.length))).toBeLessThanOrEqual(40);
262
259
  });
263
260
 
@@ -272,7 +269,7 @@ describe('default English dictionary budget (backend varchar(64))', () => {
272
269
  });
273
270
  });
274
271
 
275
- describe('per-locale loading gate (decision 5)', () => {
272
+ describe('per-locale loading gate', () => {
276
273
  function sourceFiles(dir: string, out: string[] = []): string[] {
277
274
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
278
275
  if (entry.name === 'node_modules') continue;
@@ -335,7 +332,7 @@ describe('per-locale loading gate (decision 5)', () => {
335
332
  });
336
333
  });
337
334
 
338
- describe('server surface: bound t with NO provider (decision 4a)', () => {
335
+ describe('server surface: bound t with NO provider', () => {
339
336
  function Greeting({ bound, name }: { bound: BoundThemeStrings; name: string }): JSX.Element {
340
337
  return (
341
338
  <main>
@@ -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).